오늘도 공부
Claude Code 훅(Hook) 완벽 가이드: 개발 워크플로우 자동화 마스터하기 본문
클로드 코드 훅에 대한 기본 설명은 아래 링크에서 꼭 확인하고 오세요.
Claude Code 훅(Hook) 완벽 가이드: 개발 워크플로우를 자동화하는 11가지 방법
Claude Code는 AI 기반 코딩 어시스턴트로, 다양한 훅(Hook) 이벤트를 통해 개발 프로세스를 세밀하게 제어할 수 있습니다. 이 글에서는 각 훅의 용도와 실제 활용 예제를 상세히 살펴보겠습니다.훅(Ho
javaexpert.tistory.com
AI 코딩 어시스턴트의 모든 동작을 제어하고, 팀 표준을 강제하며, 보안을 자동화하는 방법
들어가며
Claude Code를 사용하면서 이런 고민을 해본 적 있으신가요?
- "매번 프로젝트 컨텍스트를 설명하기 귀찮다"
- "실수로 프로덕션 환경에 영향을 줄까봐 불안하다"
- "코드 스타일이 일관되지 않아서 리뷰할 때 힘들다"
- "민감한 파일에 접근하는 걸 막고 싶다"
훅(Hook) 시스템이 이 모든 문제를 해결해줍니다.
이 글에서는 Claude Code의 11가지 훅 이벤트를 실제 프로젝트에 바로 적용할 수 있는 수준으로 상세히 다룹니다. 모든 예제는 복사해서 바로 사용할 수 있습니다.
훅 시스템 개요
훅이란?
훅(Hook)은 Claude Code 실행 흐름의 특정 시점에서 발생하는 이벤트입니다. 각 훅에 커스텀 로직을 연결하면 다음과 같은 자동화가 가능합니다:
목적 활용 훅
| 프로젝트 컨텍스트 자동 로드 | SessionStart |
| 위험한 명령어 차단 | PreToolUse |
| 코드 자동 포맷팅 | PostToolUse |
| 민감 파일 접근 제어 | PermissionRequest |
| 작업 완료 검증 | Stop |
| 세션 통계 리포트 | SessionEnd |
훅 실행 흐름

환경 설정
디렉토리 구조 생성
# 프로젝트 루트에서 실행
mkdir -p .claude/hooks
mkdir -p .claude/logs
mkdir -p .claude/reports
mkdir -p .claude/backups
# 기본 설정 파일 생성
touch .claude/config.yaml
touch .claude/hooks/__init__.py
기본 config.yaml
# .claude/config.yaml
version: "1.0"
# 훅 활성화 설정
hooks:
enabled: true
directory: ".claude/hooks"
# 각 훅별 설정
session_start:
enabled: true
timeout: 5000 # ms
user_prompt_submit:
enabled: true
max_enhancement_length: 2000
permission_request:
enabled: true
auto_approve_reads: true
pre_tool_use:
enabled: true
block_dangerous: true
post_tool_use:
enabled: true
auto_format: true
auto_lint: true
notification:
enabled: true
slack_webhook: "${SLACK_WEBHOOK_URL}"
stop:
enabled: true
require_tests: true
session_end:
enabled: true
generate_report: true
auto_backup: true
# 프로젝트 설정
project:
name: "My Project"
language: "dart"
framework: "flutter"
# 보안 설정
security:
sensitive_paths:
- ".env"
- ".env.local"
- "secrets/"
- "private_keys/"
- "**/credentials*"
dangerous_commands:
- "rm -rf"
- "sudo"
- "chmod 777"
- "DROP TABLE"
- "DELETE FROM"
allowed_extensions:
- ".dart"
- ".yaml"
- ".json"
- ".md"
- ".txt"
공통 유틸리티 모듈
# .claude/hooks/utils.py
"""
훅에서 공통으로 사용하는 유틸리티 함수들
"""
import os
import json
import subprocess
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Any, Optional
# 로깅 설정
logging.basicConfig(
filename='.claude/logs/hooks.log',
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('claude_hooks')
def load_config() -> Dict[str, Any]:
"""config.yaml 파일 로드"""
import yaml
config_path = Path('.claude/config.yaml')
if config_path.exists():
with open(config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
return {}
def save_json(filepath: str, data: Any) -> None:
"""JSON 파일 저장"""
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
def load_json(filepath: str) -> Optional[Dict]:
"""JSON 파일 로드"""
path = Path(filepath)
if path.exists():
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
return None
def get_git_info() -> Dict[str, str]:
"""현재 Git 정보 조회"""
try:
branch = subprocess.check_output(
['git', 'branch', '--show-current'],
stderr=subprocess.DEVNULL
).decode().strip()
commit = subprocess.check_output(
['git', 'rev-parse', 'HEAD'],
stderr=subprocess.DEVNULL
).decode().strip()[:8]
status = subprocess.check_output(
['git', 'status', '--porcelain'],
stderr=subprocess.DEVNULL
).decode().strip()
return {
'branch': branch,
'commit': commit,
'has_changes': len(status) > 0,
'changed_files': status.split('\n') if status else []
}
except subprocess.CalledProcessError:
return {'branch': 'unknown', 'commit': 'unknown', 'has_changes': False}
def run_command(command: List[str], timeout: int = 30) -> Dict[str, Any]:
"""명령어 실행 및 결과 반환"""
try:
result = subprocess.run(
command,
capture_output=True,
timeout=timeout,
text=True
)
return {
'success': result.returncode == 0,
'stdout': result.stdout,
'stderr': result.stderr,
'returncode': result.returncode
}
except subprocess.TimeoutExpired:
return {'success': False, 'error': 'timeout'}
except Exception as e:
return {'success': False, 'error': str(e)}
def backup_file(filepath: str) -> Optional[str]:
"""파일 백업 생성"""
path = Path(filepath)
if not path.exists():
return None
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_dir = Path('.claude/backups')
backup_dir.mkdir(parents=True, exist_ok=True)
backup_path = backup_dir / f"{path.stem}_{timestamp}{path.suffix}"
import shutil
shutil.copy2(path, backup_path)
logger.info(f"Backup created: {backup_path}")
return str(backup_path)
def is_sensitive_path(filepath: str) -> bool:
"""민감한 경로인지 확인"""
config = load_config()
sensitive_paths = config.get('security', {}).get('sensitive_paths', [])
from fnmatch import fnmatch
for pattern in sensitive_paths:
if fnmatch(filepath, pattern) or pattern in filepath:
return True
return False
def is_dangerous_command(command: str) -> bool:
"""위험한 명령어인지 확인"""
config = load_config()
dangerous_commands = config.get('security', {}).get('dangerous_commands', [])
command_lower = command.lower()
return any(danger in command_lower for danger in dangerous_commands)
def send_slack_notification(message: str, channel: str = '#dev-alerts') -> bool:
"""Slack 알림 전송"""
config = load_config()
webhook_url = config.get('hooks', {}).get('notification', {}).get('slack_webhook')
if not webhook_url:
logger.warning("Slack webhook URL not configured")
return False
import urllib.request
import urllib.error
try:
data = json.dumps({
'channel': channel,
'text': message,
'username': 'Claude Code Bot',
'icon_emoji': ':robot_face:'
}).encode('utf-8')
req = urllib.request.Request(
webhook_url,
data=data,
headers={'Content-Type': 'application/json'}
)
urllib.request.urlopen(req, timeout=5)
return True
except Exception as e:
logger.error(f"Slack notification failed: {e}")
return False
1. SessionStart - 세션 초기화
핵심 질문
"초기 컨텍스트나 환경 설정이 필요한가?"
활용 시나리오
- 프로젝트 README, 아키텍처 문서 자동 로드
- 환경 변수 및 개발 환경 설정
- 이전 세션 상태 복원
- 팀 코딩 스타일 가이드 주입
실전 예제: Flutter 프로젝트용
# .claude/hooks/session_start.py
"""
세션 시작 시 프로젝트 컨텍스트와 환경을 자동 설정합니다.
"""
import os
import subprocess
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, Optional
from .utils import (
load_config, load_json, save_json,
get_git_info, logger
)
def load_project_readme() -> Optional[str]:
"""프로젝트 README 로드"""
readme_paths = ['README.md', 'readme.md', 'README.rst']
for readme in readme_paths:
path = Path(readme)
if path.exists():
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
# 너무 길면 요약
if len(content) > 3000:
return content[:3000] + "\n\n... (README 요약됨)"
return content
return None
def load_architecture_docs() -> Dict[str, str]:
"""아키텍처 문서 로드"""
docs = {}
doc_paths = [
'docs/ARCHITECTURE.md',
'docs/architecture.md',
'.claude/ARCHITECTURE.md',
'ARCHITECTURE.md'
]
for doc_path in doc_paths:
path = Path(doc_path)
if path.exists():
with open(path, 'r', encoding='utf-8') as f:
docs['architecture'] = f.read()[:2000]
break
return docs
def load_coding_standards() -> str:
"""코딩 스타일 가이드 로드"""
# 커스텀 스타일 가이드가 있으면 로드
style_path = Path('.claude/STYLE_GUIDE.md')
if style_path.exists():
with open(style_path, 'r', encoding='utf-8') as f:
return f.read()
# 기본 Flutter 프로젝트 스타일 가이드
return """
## 프로젝트 코딩 규칙
### 아키텍처
- Clean Architecture 패턴 적용
- Feature-first 디렉토리 구조
- 각 Feature는 data/domain/presentation 레이어 분리
### 상태관리
- Riverpod 2.5+ 사용
- Provider는 `providers/` 디렉토리에 모음
- AsyncNotifier 패턴 선호
### 코드 스타일
- 한국어 주석 작성 (public API는 영문 dartdoc)
- const 생성자 적극 활용
- 매직 넘버 금지 (상수로 정의)
- 단일 파일 300줄 이하 유지
### 네이밍 규칙
- 파일명: snake_case (user_repository.dart)
- 클래스명: PascalCase (UserRepository)
- 변수/함수: camelCase (getUserById)
- 상수: SCREAMING_SNAKE_CASE 또는 camelCase
### 테스트
- 모든 public 메서드에 단위 테스트
- 테스트 커버리지 80% 이상 유지
- 테스트 파일명: *_test.dart
"""
def restore_previous_session() -> Optional[Dict]:
"""이전 세션 상태 복원"""
state_path = '.claude/state_snapshot.json'
return load_json(state_path)
def detect_project_type() -> Dict[str, Any]:
"""프로젝트 타입 자동 감지"""
project_info = {
'type': 'unknown',
'language': 'unknown',
'framework': None,
'package_manager': None
}
# Flutter/Dart 프로젝트
if Path('pubspec.yaml').exists():
project_info.update({
'type': 'mobile',
'language': 'dart',
'framework': 'flutter',
'package_manager': 'pub'
})
# pubspec.yaml 분석
import yaml
with open('pubspec.yaml', 'r') as f:
pubspec = yaml.safe_load(f)
project_info['name'] = pubspec.get('name', 'unknown')
project_info['dependencies'] = list(pubspec.get('dependencies', {}).keys())
# Node.js 프로젝트
elif Path('package.json').exists():
project_info.update({
'type': 'web',
'language': 'javascript',
'package_manager': 'npm'
})
# React/Next.js/Vue 등 감지
with open('package.json', 'r') as f:
package = load_json('package.json')
deps = package.get('dependencies', {})
if 'next' in deps:
project_info['framework'] = 'nextjs'
elif 'react' in deps:
project_info['framework'] = 'react'
elif 'vue' in deps:
project_info['framework'] = 'vue'
# Python 프로젝트
elif Path('requirements.txt').exists() or Path('pyproject.toml').exists():
project_info.update({
'type': 'python',
'language': 'python',
'package_manager': 'pip'
})
if Path('pyproject.toml').exists():
project_info['package_manager'] = 'poetry'
return project_info
def get_pending_tasks() -> list:
"""TODO/FIXME 항목 수집"""
pending = []
# Git 기반 TODO 추적
try:
result = subprocess.run(
['git', 'grep', '-n', '-E', '(TODO|FIXME|HACK|XXX):'],
capture_output=True,
text=True
)
if result.stdout:
lines = result.stdout.strip().split('\n')[:10] # 상위 10개만
pending = [{'type': 'code', 'item': line} for line in lines]
except:
pass
return pending
def on_session_start() -> Dict[str, Any]:
"""
세션 시작 훅 메인 함수
Returns:
Dict containing session initialization data
"""
logger.info("Session starting...")
session_data = {
'timestamp': datetime.now().isoformat(),
'session_id': datetime.now().strftime('%Y%m%d_%H%M%S'),
}
# 1. 프로젝트 타입 감지
project_info = detect_project_type()
session_data['project'] = project_info
# 2. Git 정보 로드
git_info = get_git_info()
session_data['git'] = git_info
# 3. README 로드
readme = load_project_readme()
if readme:
session_data['readme'] = readme
# 4. 아키텍처 문서 로드
arch_docs = load_architecture_docs()
session_data['architecture'] = arch_docs
# 5. 코딩 스타일 가이드 로드
session_data['coding_standards'] = load_coding_standards()
# 6. 이전 세션 상태 복원
previous_state = restore_previous_session()
if previous_state:
session_data['previous_session'] = {
'last_task': previous_state.get('current_task'),
'completed_features': previous_state.get('completed_features', []),
'pending_tasks': previous_state.get('pending_tasks', [])
}
# 7. 현재 TODO 항목 수집
session_data['pending_todos'] = get_pending_tasks()
# 8. 환경 정보
session_data['environment'] = {
'cwd': os.getcwd(),
'user': os.environ.get('USER', 'unknown'),
'shell': os.environ.get('SHELL', 'unknown')
}
# 세션 상태 저장
save_json('.claude/current_session.json', session_data)
logger.info(f"Session initialized: {session_data['session_id']}")
# Claude에게 전달할 컨텍스트 구성
context_for_claude = f"""
## 프로젝트 정보
- 이름: {project_info.get('name', 'Unknown')}
- 프레임워크: {project_info.get('framework', 'Unknown')}
- 현재 브랜치: {git_info.get('branch', 'Unknown')}
- 최근 커밋: {git_info.get('commit', 'Unknown')}
## 코딩 규칙
{session_data['coding_standards']}
## 이전 세션 정보
{f"마지막 작업: {previous_state.get('current_task')}" if previous_state else "새 세션"}
"""
return {
'session_data': session_data,
'context_for_claude': context_for_claude,
'project_type': project_info.get('framework'),
'git_branch': git_info.get('branch')
}
# 훅 실행 시 호출
if __name__ == '__main__':
result = on_session_start()
print(f"Session initialized: {result['session_data']['session_id']}")
2. UserPromptSubmit - 프롬프트 전처리
핵심 질문
"사용자 프롬프트에 컨텍스트를 추가하거나 검증이 필요한가?"
활용 시나리오
- 금지어/민감한 요청 차단
- 프로젝트 컨텍스트 자동 추가
- 프롬프트 로깅 및 감사
- 언어/용어 자동 번역
실전 예제
# .claude/hooks/user_prompt_submit.py
"""
사용자 프롬프트 제출 전 검증 및 강화
"""
import re
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional, List
from .utils import (
load_config, load_json, save_json,
is_sensitive_path, logger
)
class PromptValidator:
"""프롬프트 검증 클래스"""
def __init__(self):
self.config = load_config()
self.forbidden_patterns = [
r'api[_-]?key\s*[:=]',
r'password\s*[:=]',
r'secret\s*[:=]',
r'token\s*[:=]\s*["\']?\w{20,}',
r'private[_-]?key',
r'delete\s+from\s+\w+\s*;', # SQL DELETE
r'drop\s+(table|database)', # SQL DROP
r'rm\s+-rf\s+/', # 위험한 삭제
]
self.warning_patterns = [
r'production',
r'prod\s+environment',
r'live\s+server',
r'deploy\s+to\s+main',
]
def check_forbidden(self, prompt: str) -> Optional[Dict]:
"""금지된 패턴 검사"""
prompt_lower = prompt.lower()
for pattern in self.forbidden_patterns:
if re.search(pattern, prompt_lower, re.IGNORECASE):
return {
'blocked': True,
'reason': f'보안상 민감한 패턴이 감지되었습니다: {pattern}',
'pattern': pattern
}
return None
def check_warnings(self, prompt: str) -> List[str]:
"""경고 패턴 검사"""
warnings = []
prompt_lower = prompt.lower()
for pattern in self.warning_patterns:
if re.search(pattern, prompt_lower, re.IGNORECASE):
warnings.append(f"'{pattern}' 관련 작업 - 주의가 필요합니다")
return warnings
class PromptEnhancer:
"""프롬프트 강화 클래스"""
def __init__(self):
self.config = load_config()
self.current_session = load_json('.claude/current_session.json') or {}
def add_project_context(self, prompt: str) -> str:
"""프로젝트 컨텍스트 추가"""
project = self.current_session.get('project', {})
git = self.current_session.get('git', {})
context = f"""
[현재 프로젝트 컨텍스트]
- 프레임워크: {project.get('framework', 'Unknown')}
- 브랜치: {git.get('branch', 'Unknown')}
- 의존성: {', '.join(project.get('dependencies', [])[:5])}
"""
return prompt + context
def add_coding_standards(self, prompt: str) -> str:
"""코딩 작업 시 스타일 가이드 추가"""
coding_keywords = ['생성', '작성', 'create', 'write', '코드', 'code', '함수', 'function', '클래스', 'class']
if any(keyword in prompt.lower() for keyword in coding_keywords):
standards = self.current_session.get('coding_standards', '')
if standards:
return prompt + f"\n\n[적용할 코딩 규칙]\n{standards[:500]}"
return prompt
def add_test_reminder(self, prompt: str) -> str:
"""테스트 작성 리마인더 추가"""
creation_keywords = ['만들', '생성', '작성', 'create', 'implement', 'add']
if any(keyword in prompt.lower() for keyword in creation_keywords):
return prompt + "\n\n[참고: 새로운 기능 구현 시 단위 테스트도 함께 작성해주세요]"
return prompt
def detect_and_add_file_context(self, prompt: str) -> str:
"""언급된 파일의 컨텍스트 자동 추가"""
# 파일 경로 패턴 감지
file_patterns = [
r'[\w/]+\.dart',
r'[\w/]+\.py',
r'[\w/]+\.ts',
r'[\w/]+\.js',
]
mentioned_files = []
for pattern in file_patterns:
matches = re.findall(pattern, prompt)
mentioned_files.extend(matches)
if not mentioned_files:
return prompt
file_contexts = []
for filepath in mentioned_files[:3]: # 최대 3개 파일
path = Path(filepath)
if path.exists() and path.stat().st_size < 5000: # 5KB 이하만
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
file_contexts.append(f"### {filepath}\n```\n{content[:1000]}\n```")
if file_contexts:
context = "\n\n[참조된 파일 내용]\n" + "\n".join(file_contexts)
return prompt + context
return prompt
class PromptLogger:
"""프롬프트 로깅 클래스"""
def __init__(self):
self.log_dir = Path('.claude/logs/prompts')
self.log_dir.mkdir(parents=True, exist_ok=True)
def log(self, original: str, enhanced: str, metadata: Dict) -> None:
"""프롬프트 로그 저장"""
timestamp = datetime.now()
log_entry = {
'timestamp': timestamp.isoformat(),
'original_prompt': original,
'enhanced_prompt': enhanced,
'enhancement_applied': original != enhanced,
'metadata': metadata
}
# 일별 로그 파일
log_file = self.log_dir / f"prompts_{timestamp.strftime('%Y%m%d')}.jsonl"
with open(log_file, 'a', encoding='utf-8') as f:
import json
f.write(json.dumps(log_entry, ensure_ascii=False) + '\n')
def on_user_prompt_submit(prompt: str, metadata: Optional[Dict] = None) -> Dict[str, Any]:
"""
프롬프트 제출 훅 메인 함수
Args:
prompt: 사용자가 입력한 프롬프트
metadata: 추가 메타데이터
Returns:
Dict containing processed prompt or block information
"""
logger.info(f"Processing prompt: {prompt[:100]}...")
metadata = metadata or {}
# 1. 검증
validator = PromptValidator()
# 금지 패턴 체크
forbidden_check = validator.check_forbidden(prompt)
if forbidden_check:
logger.warning(f"Prompt blocked: {forbidden_check['reason']}")
return {
'block': True,
'reason': forbidden_check['reason'],
'original_prompt': prompt
}
# 경고 패턴 체크
warnings = validator.check_warnings(prompt)
# 2. 강화
enhancer = PromptEnhancer()
enhanced_prompt = prompt
# 프로젝트 컨텍스트 추가
enhanced_prompt = enhancer.add_project_context(enhanced_prompt)
# 코딩 스타일 가이드 추가
enhanced_prompt = enhancer.add_coding_standards(enhanced_prompt)
# 테스트 리마인더 추가
enhanced_prompt = enhancer.add_test_reminder(enhanced_prompt)
# 파일 컨텍스트 추가
enhanced_prompt = enhancer.detect_and_add_file_context(enhanced_prompt)
# 3. 로깅
prompt_logger = PromptLogger()
prompt_logger.log(prompt, enhanced_prompt, {
**metadata,
'warnings': warnings
})
result = {
'enhanced_prompt': enhanced_prompt,
'original_prompt': prompt,
'warnings': warnings,
'enhancements_applied': prompt != enhanced_prompt
}
logger.info(f"Prompt processed. Enhancements: {result['enhancements_applied']}")
return result
# 사용 예시
if __name__ == '__main__':
# 테스트 프롬프트
test_prompts = [
"UserRepository 클래스를 생성해주세요",
"api_key = 'sk-xxxx' 형태로 설정 파일 만들어줘",
"production 서버에 배포하는 스크립트 작성",
]
for prompt in test_prompts:
print(f"\n원본: {prompt}")
result = on_user_prompt_submit(prompt)
if result.get('block'):
print(f"❌ 차단됨: {result['reason']}")
else:
print(f"✅ 처리됨 (경고: {result.get('warnings', [])})")
3. PermissionRequest - 권한 자동화
핵심 질문
"이 권한 요청을 자동으로 승인/거부해야 하나?"
활용 시나리오
- 읽기 전용 파일 자동 승인
- 민감한 파일 자동 거부
- 파일 수정 전 백업 생성
- 권한 감사 로그 작성
실전 예제
# .claude/hooks/permission_request.py
"""
파일/리소스 접근 권한 자동 처리
"""
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, Optional
from .utils import (
load_config, save_json, backup_file,
is_sensitive_path, logger
)
class PermissionPolicy:
"""권한 정책 관리"""
def __init__(self):
self.config = load_config()
self.security_config = self.config.get('security', {})
# 자동 승인 파일 확장자
self.auto_approve_extensions = {
'read': ['.dart', '.py', '.js', '.ts', '.json', '.yaml', '.yml',
'.md', '.txt', '.html', '.css', '.xml', '.toml'],
'write': ['.dart', '.py', '.js', '.ts', '.md', '.txt']
}
# 자동 거부 경로 패턴
self.auto_deny_patterns = [
'.env', '.env.local', '.env.production',
'secrets/', 'private/', 'credentials/',
'*.pem', '*.key', '*.p12', '*.pfx',
'.git/config', '.git/credentials',
'id_rsa', 'id_ed25519',
]
# 특별 보호 파일
self.protected_files = [
'pubspec.lock', # 의존성 잠금
'package-lock.json',
'poetry.lock',
]
def is_auto_approve(self, request_type: str, filepath: str) -> bool:
"""자동 승인 가능 여부"""
path = Path(filepath)
extension = path.suffix.lower()
# 민감한 경로는 절대 자동 승인 안함
if is_sensitive_path(filepath):
return False
# 확장자 기반 자동 승인
allowed_extensions = self.auto_approve_extensions.get(request_type, [])
return extension in allowed_extensions
def is_auto_deny(self, filepath: str) -> Optional[str]:
"""자동 거부 여부 및 사유"""
from fnmatch import fnmatch
for pattern in self.auto_deny_patterns:
if fnmatch(filepath, f'*{pattern}*') or pattern in filepath:
return f"보안 정책에 의해 '{pattern}' 패턴 접근이 차단되었습니다"
return None
def is_protected(self, filepath: str) -> bool:
"""보호된 파일 여부"""
return Path(filepath).name in self.protected_files
class PermissionAuditor:
"""권한 감사 로깅"""
def __init__(self):
self.audit_dir = Path('.claude/logs/audit')
self.audit_dir.mkdir(parents=True, exist_ok=True)
def log(self, request: Dict, decision: str, reason: str) -> None:
"""감사 로그 기록"""
timestamp = datetime.now()
audit_entry = {
'timestamp': timestamp.isoformat(),
'request_type': request.get('type'),
'path': request.get('path'),
'decision': decision, # 'approved', 'denied', 'user_confirm'
'reason': reason,
'request_details': request
}
# 일별 감사 로그
audit_file = self.audit_dir / f"audit_{timestamp.strftime('%Y%m%d')}.jsonl"
with open(audit_file, 'a', encoding='utf-8') as f:
import json
f.write(json.dumps(audit_entry, ensure_ascii=False) + '\n')
logger.info(f"Permission {decision}: {request.get('path')} - {reason}")
def on_permission_request(request: Dict[str, Any]) -> Dict[str, Any]:
"""
권한 요청 훅 메인 함수
Args:
request: 권한 요청 정보
- type: 'file_read', 'file_write', 'file_delete', 'execute', etc.
- path: 대상 파일/리소스 경로
- operation: 구체적인 작업 내용
Returns:
Dict containing permission decision
"""
request_type = request.get('type', 'unknown')
filepath = request.get('path', '')
logger.info(f"Permission request: {request_type} - {filepath}")
policy = PermissionPolicy()
auditor = PermissionAuditor()
# 1. 자동 거부 체크
deny_reason = policy.is_auto_deny(filepath)
if deny_reason:
auditor.log(request, 'denied', deny_reason)
return {
'deny': True,
'reason': deny_reason,
'notify_user': True,
'suggest_alternative': "민감한 정보가 포함된 파일입니다. 다른 방법을 사용해주세요."
}
# 2. 파일 읽기 요청
if request_type == 'file_read':
if policy.is_auto_approve('read', filepath):
auditor.log(request, 'approved', '안전한 읽기 작업')
return {'approve': True, 'log': True}
else:
auditor.log(request, 'user_confirm', '수동 확인 필요')
return {
'require_user_confirmation': True,
'message': f"'{filepath}' 파일 읽기를 승인하시겠습니까?"
}
# 3. 파일 쓰기 요청
if request_type == 'file_write':
path = Path(filepath)
# 보호된 파일 체크
if policy.is_protected(filepath):
auditor.log(request, 'denied', '보호된 파일')
return {
'deny': True,
'reason': f"'{path.name}'은(는) 보호된 파일입니다. 직접 수정할 수 없습니다.",
'notify_user': True
}
# 기존 파일이면 백업 생성
backup_path = None
if path.exists():
backup_path = backup_file(filepath)
if policy.is_auto_approve('write', filepath):
auditor.log(request, 'approved', f'자동 승인 (백업: {backup_path})')
return {
'approve': True,
'backup_created': backup_path is not None,
'backup_path': backup_path
}
else:
auditor.log(request, 'user_confirm', '수동 확인 필요')
return {
'require_user_confirmation': True,
'message': f"'{filepath}' 파일 쓰기를 승인하시겠습니까?",
'backup_will_be_created': path.exists()
}
# 4. 파일 삭제 요청
if request_type == 'file_delete':
# 삭제 전 항상 백업
backup_path = backup_file(filepath) if Path(filepath).exists() else None
auditor.log(request, 'user_confirm', '삭제 작업은 항상 확인 필요')
return {
'require_user_confirmation': True,
'message': f"⚠️ '{filepath}' 파일을 삭제하시겠습니까?",
'backup_created': backup_path is not None,
'backup_path': backup_path,
'warning': "삭제 작업은 되돌릴 수 없습니다."
}
# 5. 실행 권한 요청
if request_type == 'execute':
command = request.get('command', '')
# 위험한 명령어 체크
from .utils import is_dangerous_command
if is_dangerous_command(command):
auditor.log(request, 'denied', '위험한 명령어')
return {
'deny': True,
'reason': f"위험한 명령어가 감지되었습니다: {command[:50]}",
'notify_user': True
}
auditor.log(request, 'user_confirm', '실행 명령 확인 필요')
return {
'require_user_confirmation': True,
'message': f"다음 명령을 실행하시겠습니까?\n`{command}`"
}
# 6. 알 수 없는 요청 타입
auditor.log(request, 'user_confirm', '알 수 없는 요청 타입')
return {
'require_user_confirmation': True,
'message': f"'{request_type}' 작업을 승인하시겠습니까?"
}
# 테스트
if __name__ == '__main__':
test_requests = [
{'type': 'file_read', 'path': 'lib/main.dart'},
{'type': 'file_read', 'path': '.env'},
{'type': 'file_write', 'path': 'lib/new_feature.dart'},
{'type': 'file_delete', 'path': 'temp_file.dart'},
{'type': 'execute', 'command': 'flutter build apk'},
{'type': 'execute', 'command': 'rm -rf /'},
]
for req in test_requests:
print(f"\n요청: {req}")
result = on_permission_request(req)
print(f"결과: {result}")
4. PreToolUse - 도구 실행 전 제어
핵심 질문
"도구 실행을 허용, 수정 또는 차단해야 하나?"
활용 시나리오
- 위험한 셸 명령어 차단
- API 호출 rate limit 체크
- 파라미터 자동 수정
- 실행 전 검증
실전 예제
# .claude/hooks/pre_tool_use.py
"""
도구 사용 전 보안 검증 및 파라미터 조정
"""
import re
import time
from pathlib import Path
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List, Tuple
from collections import defaultdict
from .utils import (
load_config, load_json, save_json,
is_sensitive_path, is_dangerous_command, logger
)
class CommandSecurityChecker:
"""명령어 보안 검사"""
# 위험 등급별 명령어 패턴
CRITICAL_PATTERNS = [
(r'rm\s+-rf\s+/', "루트 디렉토리 삭제 시도"),
(r'rm\s+-rf\s+~', "홈 디렉토리 삭제 시도"),
(r'rm\s+-rf\s+\*', "와일드카드 삭제"),
(r':(){ :\|:& };:', "Fork bomb 감지"),
(r'dd\s+if=/dev/zero', "디스크 초기화 시도"),
(r'mkfs\.', "파일시스템 포맷 시도"),
(r'chmod\s+-R\s+777', "전체 권한 변경"),
(r'>\s*/dev/sd', "디스크 직접 쓰기"),
]
HIGH_RISK_PATTERNS = [
(r'\bsudo\b', "관리자 권한 사용"),
(r'\bsu\s+-', "사용자 전환"),
(r'curl.*\|\s*(ba)?sh', "원격 스크립트 실행"),
(r'wget.*\|\s*(ba)?sh', "원격 스크립트 실행"),
(r'eval\s+', "동적 코드 실행"),
(r'>\s*/etc/', "/etc 파일 수정"),
]
MEDIUM_RISK_PATTERNS = [
(r'npm\s+publish', "패키지 배포"),
(r'pip\s+.*--user', "사용자 패키지 설치"),
(r'git\s+push.*--force', "강제 푸시"),
(r'docker\s+rm', "컨테이너 삭제"),
(r'kubectl\s+delete', "K8s 리소스 삭제"),
]
def check(self, command: str) -> Tuple[str, Optional[str]]:
"""
명령어 위험도 검사
Returns:
Tuple[risk_level, reason]: ('critical', 'high', 'medium', 'low'), 사유
"""
for pattern, reason in self.CRITICAL_PATTERNS:
if re.search(pattern, command, re.IGNORECASE):
return ('critical', reason)
for pattern, reason in self.HIGH_RISK_PATTERNS:
if re.search(pattern, command, re.IGNORECASE):
return ('high', reason)
for pattern, reason in self.MEDIUM_RISK_PATTERNS:
if re.search(pattern, command, re.IGNORECASE):
return ('medium', reason)
return ('low', None)
class RateLimiter:
"""API 호출 Rate Limit 관리"""
def __init__(self):
self.state_file = Path('.claude/rate_limit_state.json')
self.limits = {
'default': {'calls': 100, 'window': 60}, # 분당 100회
'api_call': {'calls': 30, 'window': 60}, # 분당 30회
'web_search': {'calls': 10, 'window': 60}, # 분당 10회
'file_write': {'calls': 50, 'window': 60}, # 분당 50회
}
self._load_state()
def _load_state(self):
"""상태 로드"""
if self.state_file.exists():
self.state = load_json(str(self.state_file)) or {}
else:
self.state = {}
def _save_state(self):
"""상태 저장"""
save_json(str(self.state_file), self.state)
def check(self, operation: str) -> Tuple[bool, Optional[int]]:
"""
Rate limit 체크
Returns:
Tuple[allowed, retry_after_seconds]
"""
limit_config = self.limits.get(operation, self.limits['default'])
max_calls = limit_config['calls']
window = limit_config['window']
now = time.time()
key = f"rate_{operation}"
if key not in self.state:
self.state[key] = {'calls': [], 'window_start': now}
# 윈도우 내 호출 필터링
self.state[key]['calls'] = [
t for t in self.state[key]['calls']
if now - t < window
]
current_calls = len(self.state[key]['calls'])
if current_calls >= max_calls:
oldest_call = min(self.state[key]['calls'])
retry_after = int(window - (now - oldest_call))
return (False, retry_after)
# 호출 기록
self.state[key]['calls'].append(now)
self._save_state()
return (True, None)
class ParameterSanitizer:
"""파라미터 정제"""
def __init__(self):
self.config = load_config()
def sanitize_file_path(self, path: str) -> str:
"""파일 경로 정제"""
# 상대 경로로 변환
if path.startswith('/'):
path = path.lstrip('/')
# .. 제거
path = re.sub(r'\.\./', '', path)
# 위험한 문자 제거
path = re.sub(r'[;&|`$]', '', path)
return path
def sanitize_command(self, command: str) -> str:
"""명령어 정제"""
# 명령어 체이닝 감지 및 분리
if '&&' in command or '||' in command or ';' in command:
# 첫 번째 명령만 허용
command = re.split(r'[;&|]{1,2}', command)[0].strip()
logger.warning(f"Command chaining detected, using only first command: {command}")
return command
def on_pre_tool_use(tool_name: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
"""
도구 실행 전 훅 메인 함수
Args:
tool_name: 사용할 도구 이름
parameters: 도구 파라미터
Returns:
Dict containing allow/block/modify decision
"""
logger.info(f"Pre-tool check: {tool_name} with params: {parameters}")
security_checker = CommandSecurityChecker()
rate_limiter = RateLimiter()
sanitizer = ParameterSanitizer()
# 1. Bash 명령어 검사
if tool_name == 'bash':
command = parameters.get('command', '')
# 보안 검사
risk_level, reason = security_checker.check(command)
if risk_level == 'critical':
logger.error(f"Critical command blocked: {command}")
return {
'block': True,
'reason': f"🚫 위험한 명령어 차단: {reason}",
'risk_level': risk_level,
'notify_user': True
}
if risk_level == 'high':
logger.warning(f"High-risk command requires confirmation: {command}")
return {
'require_confirmation': True,
'reason': f"⚠️ 고위험 명령어: {reason}",
'risk_level': risk_level,
'command': command
}
if risk_level == 'medium':
# 명령어 정제 후 진행
sanitized_command = sanitizer.sanitize_command(command)
if sanitized_command != command:
return {
'modify': True,
'modified_parameters': {'command': sanitized_command},
'reason': f"명령어가 정제되었습니다 (위험도: {risk_level})",
'original_command': command
}
return {'allow': True}
# 2. 파일 생성/쓰기 검사
if tool_name in ['create_file', 'file_write', 'str_replace']:
filepath = parameters.get('path', '')
# 민감한 경로 체크
if is_sensitive_path(filepath):
return {
'block': True,
'reason': f"🔒 보안 정책: 민감한 경로에 대한 쓰기가 차단되었습니다",
'path': filepath
}
# 경로 정제
sanitized_path = sanitizer.sanitize_file_path(filepath)
if sanitized_path != filepath:
parameters['path'] = sanitized_path
return {
'modify': True,
'modified_parameters': parameters,
'reason': f"파일 경로가 정제되었습니다: {filepath} -> {sanitized_path}"
}
# Rate limit 체크
allowed, retry_after = rate_limiter.check('file_write')
if not allowed:
return {
'block': True,
'reason': f"파일 쓰기 Rate limit 초과",
'retry_after': retry_after
}
return {'allow': True}
# 3. API 호출 검사
if tool_name == 'api_call':
endpoint = parameters.get('endpoint', '')
method = parameters.get('method', 'GET')
# Rate limit 체크
allowed, retry_after = rate_limiter.check('api_call')
if not allowed:
return {
'block': True,
'reason': f"API Rate limit 초과",
'retry_after': retry_after
}
# 위험한 엔드포인트 체크
dangerous_endpoints = ['/admin', '/delete', '/drop', '/truncate']
if any(ep in endpoint.lower() for ep in dangerous_endpoints):
return {
'require_confirmation': True,
'reason': f"⚠️ 민감한 API 엔드포인트: {endpoint}",
'method': method
}
return {'allow': True}
# 4. 웹 검색 검사
if tool_name == 'web_search':
query = parameters.get('query', '')
# Rate limit 체크
allowed, retry_after = rate_limiter.check('web_search')
if not allowed:
return {
'block': True,
'reason': f"웹 검색 Rate limit 초과",
'retry_after': retry_after
}
return {'allow': True}
# 5. 기타 도구는 기본 허용
return {'allow': True}
# 테스트
if __name__ == '__main__':
test_cases = [
('bash', {'command': 'ls -la'}),
('bash', {'command': 'rm -rf /'}),
('bash', {'command': 'sudo apt update'}),
('create_file', {'path': 'lib/new_file.dart', 'content': '...'}),
('create_file', {'path': '.env', 'content': '...'}),
('api_call', {'endpoint': '/api/users', 'method': 'GET'}),
('api_call', {'endpoint': '/admin/delete', 'method': 'DELETE'}),
]
for tool, params in test_cases:
print(f"\n도구: {tool}, 파라미터: {params}")
result = on_pre_tool_use(tool, params)
print(f"결과: {result}")
5. PostToolUse - 도구 실행 후 처리
핵심 질문
"결과를 검증하거나 후처리가 필요한가?"
활용 시나리오
- 코드 자동 포맷팅
- 린트 체크 실행
- 테스트 자동 실행
- Git 스테이징
실전 예제
# .claude/hooks/post_tool_use.py
"""
도구 실행 후 자동 처리 및 검증
"""
import subprocess
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, Optional, List
from .utils import (
load_config, run_command, logger, save_json
)
class CodeFormatter:
"""코드 자동 포맷터"""
FORMATTERS = {
'.dart': {
'command': ['dart', 'format'],
'check_command': ['dart', 'format', '--output=none', '--set-exit-if-changed']
},
'.py': {
'command': ['black'],
'check_command': ['black', '--check']
},
'.js': {
'command': ['prettier', '--write'],
'check_command': ['prettier', '--check']
},
'.ts': {
'command': ['prettier', '--write'],
'check_command': ['prettier', '--check']
},
'.json': {
'command': ['prettier', '--write'],
'check_command': ['prettier', '--check']
}
}
def format(self, filepath: str) -> Dict[str, Any]:
"""파일 포맷팅"""
path = Path(filepath)
extension = path.suffix.lower()
if extension not in self.FORMATTERS:
return {'formatted': False, 'reason': f'No formatter for {extension}'}
formatter = self.FORMATTERS[extension]
result = run_command(formatter['command'] + [filepath])
return {
'formatted': result['success'],
'output': result.get('stdout', ''),
'error': result.get('stderr', '')
}
def check(self, filepath: str) -> Dict[str, Any]:
"""포맷팅 체크 (수정 없이)"""
path = Path(filepath)
extension = path.suffix.lower()
if extension not in self.FORMATTERS:
return {'needs_formatting': False}
formatter = self.FORMATTERS[extension]
result = run_command(formatter['check_command'] + [filepath])
return {
'needs_formatting': not result['success'],
'output': result.get('stdout', '')
}
class CodeLinter:
"""코드 린터"""
LINTERS = {
'.dart': {
'command': ['dart', 'analyze'],
'error_pattern': r'error\s+•|Error:',
'warning_pattern': r'warning\s+•|Warning:'
},
'.py': {
'command': ['flake8'],
'error_pattern': r'E\d{3}',
'warning_pattern': r'W\d{3}'
},
'.js': {
'command': ['eslint'],
'error_pattern': r'error',
'warning_pattern': r'warning'
},
'.ts': {
'command': ['eslint'],
'error_pattern': r'error',
'warning_pattern': r'warning'
}
}
def lint(self, filepath: str) -> Dict[str, Any]:
"""린트 실행"""
import re
path = Path(filepath)
extension = path.suffix.lower()
if extension not in self.LINTERS:
return {'linted': False, 'reason': f'No linter for {extension}'}
linter = self.LINTERS[extension]
result = run_command(linter['command'] + [filepath])
output = result.get('stdout', '') + result.get('stderr', '')
errors = len(re.findall(linter['error_pattern'], output, re.IGNORECASE))
warnings = len(re.findall(linter['warning_pattern'], output, re.IGNORECASE))
return {
'linted': True,
'passed': errors == 0,
'errors': errors,
'warnings': warnings,
'output': output
}
class TestRunner:
"""테스트 실행기"""
def __init__(self):
self.config = load_config()
def find_test_file(self, source_file: str) -> Optional[str]:
"""소스 파일에 대응하는 테스트 파일 찾기"""
path = Path(source_file)
# Dart/Flutter 패턴
if path.suffix == '.dart':
# lib/feature.dart -> test/feature_test.dart
if 'lib/' in str(path):
test_path = str(path).replace('lib/', 'test/').replace('.dart', '_test.dart')
if Path(test_path).exists():
return test_path
# Python 패턴
elif path.suffix == '.py':
# src/module.py -> tests/test_module.py
test_path = str(path).replace('src/', 'tests/').replace(path.stem, f'test_{path.stem}')
if Path(test_path).exists():
return test_path
return None
def run_tests(self, test_file: str) -> Dict[str, Any]:
"""테스트 실행"""
path = Path(test_file)
if path.suffix == '.dart':
result = run_command(['flutter', 'test', test_file], timeout=120)
elif path.suffix == '.py':
result = run_command(['pytest', test_file, '-v'], timeout=120)
elif path.suffix in ['.js', '.ts']:
result = run_command(['npm', 'test', '--', test_file], timeout=120)
else:
return {'ran': False, 'reason': f'Unknown test file type: {path.suffix}'}
return {
'ran': True,
'passed': result['success'],
'output': result.get('stdout', ''),
'error': result.get('stderr', '')
}
class GitStager:
"""Git 스테이징 관리"""
def stage_file(self, filepath: str) -> Dict[str, Any]:
"""파일 스테이징"""
result = run_command(['git', 'add', filepath])
return {
'staged': result['success'],
'error': result.get('stderr', '')
}
def get_diff(self, filepath: str) -> str:
"""파일 변경 내용"""
result = run_command(['git', 'diff', filepath])
return result.get('stdout', '')
def on_post_tool_use(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
"""
도구 실행 후 훅 메인 함수
Args:
tool_name: 실행된 도구 이름
result: 도구 실행 결과
- path: 생성/수정된 파일 경로
- content: 파일 내용
- success: 성공 여부
Returns:
Dict containing post-processing results
"""
logger.info(f"Post-tool processing: {tool_name}")
config = load_config()
post_config = config.get('hooks', {}).get('post_tool_use', {})
response = {
'tool_name': tool_name,
'original_result': result,
'post_processing': {}
}
# 파일 생성/수정 후 처리
if tool_name in ['create_file', 'file_write', 'str_replace']:
filepath = result.get('path', '')
path = Path(filepath)
if not path.exists():
return response
# 1. 코드 포맷팅
if post_config.get('auto_format', True):
formatter = CodeFormatter()
format_result = formatter.format(filepath)
response['post_processing']['formatting'] = format_result
if format_result.get('formatted'):
logger.info(f"File formatted: {filepath}")
# 2. 린트 체크
if post_config.get('auto_lint', True):
linter = CodeLinter()
lint_result = linter.lint(filepath)
response['post_processing']['linting'] = lint_result
if lint_result.get('errors', 0) > 0:
response['warnings'] = response.get('warnings', [])
response['warnings'].append({
'type': 'lint_errors',
'message': f"린트 오류 {lint_result['errors']}개 발견",
'suggest_fix': True
})
logger.warning(f"Lint errors in {filepath}: {lint_result['errors']}")
# 3. 관련 테스트 실행
if post_config.get('auto_test', False):
test_runner = TestRunner()
test_file = test_runner.find_test_file(filepath)
if test_file:
test_result = test_runner.run_tests(test_file)
response['post_processing']['testing'] = test_result
if not test_result.get('passed', True):
response['warnings'] = response.get('warnings', [])
response['warnings'].append({
'type': 'test_failure',
'message': f"테스트 실패: {test_file}",
'output': test_result.get('output', '')
})
# 4. Git 스테이징
if post_config.get('auto_git_stage', False):
stager = GitStager()
stage_result = stager.stage_file(filepath)
response['post_processing']['git'] = stage_result
# 5. 테스트 파일 생성 제안 (테스트 파일이 없을 경우)
test_runner = TestRunner()
if (path.suffix in ['.dart', '.py', '.js', '.ts']
and '_test' not in str(path)
and 'test_' not in str(path)
and not test_runner.find_test_file(filepath)):
response['suggestions'] = response.get('suggestions', [])
response['suggestions'].append({
'type': 'missing_test',
'message': f"'{path.name}'에 대한 테스트 파일이 없습니다. 테스트를 작성해주세요."
})
# Bash 명령어 실행 후
if tool_name == 'bash':
command = result.get('command', '')
output = result.get('stdout', '') + result.get('stderr', '')
# 에러 패턴 감지
error_patterns = [
r'error:', r'Error:', r'ERROR:',
r'failed', r'Failed', r'FAILED',
r'exception', r'Exception',
r'not found', r'No such file'
]
import re
for pattern in error_patterns:
if re.search(pattern, output):
response['warnings'] = response.get('warnings', [])
response['warnings'].append({
'type': 'command_error',
'message': f"명령어 실행 중 오류 패턴 감지: {pattern}",
'output': output[:500]
})
break
# 세션 상태 업데이트
_update_session_state(tool_name, result, response)
return response
def _update_session_state(tool_name: str, result: Dict, response: Dict):
"""세션 상태 업데이트"""
from .utils import load_json, save_json
state = load_json('.claude/current_session.json') or {}
if 'tool_usage' not in state:
state['tool_usage'] = []
state['tool_usage'].append({
'tool': tool_name,
'timestamp': datetime.now().isoformat(),
'path': result.get('path'),
'success': result.get('success', True),
'warnings': len(response.get('warnings', []))
})
# 생성된 파일 추적
if tool_name == 'create_file' and result.get('path'):
if 'created_files' not in state:
state['created_files'] = []
state['created_files'].append(result['path'])
# 수정된 파일 추적
if tool_name in ['file_write', 'str_replace'] and result.get('path'):
if 'modified_files' not in state:
state['modified_files'] = []
if result['path'] not in state['modified_files']:
state['modified_files'].append(result['path'])
save_json('.claude/current_session.json', state)
# 테스트
if __name__ == '__main__':
# 테스트용 파일 생성
test_result = {
'path': 'lib/test_feature.dart',
'success': True,
'content': '''
class TestFeature {
void doSomething() {
print("Hello");
}
}
'''
}
result = on_post_tool_use('create_file', test_result)
print(f"결과: {result}")
6. Notification - 알림 커스터마이징
핵심 질문
"사용자에게 어떤 방식으로 알림을 보낼까?"
실전 예제
# .claude/hooks/notification.py
"""
알림 시스템 커스터마이징
"""
import os
import json
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional
from enum import Enum
from .utils import load_config, logger
class NotificationType(Enum):
INFO = 'info'
SUCCESS = 'success'
WARNING = 'warning'
ERROR = 'error'
SECURITY_ALERT = 'security_alert'
TASK_COMPLETE = 'task_complete'
TASK_START = 'task_start'
class NotificationChannel:
"""알림 채널 기본 클래스"""
def send(self, notification_type: NotificationType, message: str,
metadata: Optional[Dict] = None) -> bool:
raise NotImplementedError
class SlackNotification(NotificationChannel):
"""Slack 알림"""
ICONS = {
NotificationType.INFO: ':information_source:',
NotificationType.SUCCESS: ':white_check_mark:',
NotificationType.WARNING: ':warning:',
NotificationType.ERROR: ':x:',
NotificationType.SECURITY_ALERT: ':rotating_light:',
NotificationType.TASK_COMPLETE: ':tada:',
NotificationType.TASK_START: ':rocket:',
}
COLORS = {
NotificationType.INFO: '#36a64f',
NotificationType.SUCCESS: '#2eb886',
NotificationType.WARNING: '#daa038',
NotificationType.ERROR: '#cc0000',
NotificationType.SECURITY_ALERT: '#ff0000',
NotificationType.TASK_COMPLETE: '#36a64f',
NotificationType.TASK_START: '#439fe0',
}
def __init__(self, webhook_url: str, channel: str = '#dev-alerts'):
self.webhook_url = webhook_url
self.channel = channel
def send(self, notification_type: NotificationType, message: str,
metadata: Optional[Dict] = None) -> bool:
if not self.webhook_url:
return False
icon = self.ICONS.get(notification_type, ':robot_face:')
color = self.COLORS.get(notification_type, '#36a64f')
payload = {
'channel': self.channel,
'username': 'Claude Code Bot',
'icon_emoji': icon,
'attachments': [{
'color': color,
'title': f'{icon} {notification_type.value.upper()}',
'text': message,
'footer': 'Claude Code',
'ts': int(datetime.now().timestamp())
}]
}
if metadata:
fields = [
{'title': k, 'value': str(v), 'short': True}
for k, v in metadata.items()
]
payload['attachments'][0]['fields'] = fields[:10] # 최대 10개
try:
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(
self.webhook_url,
data=data,
headers={'Content-Type': 'application/json'}
)
urllib.request.urlopen(req, timeout=5)
return True
except Exception as e:
logger.error(f"Slack notification failed: {e}")
return False
class DiscordNotification(NotificationChannel):
"""Discord 알림"""
COLORS = {
NotificationType.INFO: 0x3498db,
NotificationType.SUCCESS: 0x2ecc71,
NotificationType.WARNING: 0xf39c12,
NotificationType.ERROR: 0xe74c3c,
NotificationType.SECURITY_ALERT: 0xe74c3c,
NotificationType.TASK_COMPLETE: 0x2ecc71,
NotificationType.TASK_START: 0x3498db,
}
def __init__(self, webhook_url: str):
self.webhook_url = webhook_url
def send(self, notification_type: NotificationType, message: str,
metadata: Optional[Dict] = None) -> bool:
if not self.webhook_url:
return False
color = self.COLORS.get(notification_type, 0x3498db)
embed = {
'title': f'{notification_type.value.upper()}',
'description': message,
'color': color,
'timestamp': datetime.utcnow().isoformat(),
'footer': {'text': 'Claude Code'}
}
if metadata:
embed['fields'] = [
{'name': k, 'value': str(v)[:100], 'inline': True}
for k, v in list(metadata.items())[:10]
]
payload = {'embeds': [embed]}
try:
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(
self.webhook_url,
data=data,
headers={'Content-Type': 'application/json'}
)
urllib.request.urlopen(req, timeout=5)
return True
except Exception as e:
logger.error(f"Discord notification failed: {e}")
return False
class DesktopNotification(NotificationChannel):
"""데스크톱 알림"""
def send(self, notification_type: NotificationType, message: str,
metadata: Optional[Dict] = None) -> bool:
try:
from plyer import notification as plyer_notify
plyer_notify.notify(
title=f'Claude Code - {notification_type.value.upper()}',
message=message[:256], # 길이 제한
app_name='Claude Code',
timeout=10
)
return True
except ImportError:
logger.warning("plyer not installed, skipping desktop notification")
return False
except Exception as e:
logger.error(f"Desktop notification failed: {e}")
return False
class FileLogger(NotificationChannel):
"""파일 로깅"""
def __init__(self, log_dir: str = '.claude/logs'):
self.log_dir = Path(log_dir)
self.log_dir.mkdir(parents=True, exist_ok=True)
def send(self, notification_type: NotificationType, message: str,
metadata: Optional[Dict] = None) -> bool:
log_entry = {
'timestamp': datetime.now().isoformat(),
'type': notification_type.value,
'message': message,
'metadata': metadata or {}
}
log_file = self.log_dir / f"notifications_{datetime.now().strftime('%Y%m%d')}.jsonl"
try:
with open(log_file, 'a', encoding='utf-8') as f:
f.write(json.dumps(log_entry, ensure_ascii=False) + '\n')
return True
except Exception as e:
logger.error(f"File logging failed: {e}")
return False
class NotificationManager:
"""알림 관리자"""
def __init__(self):
self.config = load_config()
self.channels: list[NotificationChannel] = []
self._setup_channels()
def _setup_channels(self):
"""채널 설정"""
notification_config = self.config.get('hooks', {}).get('notification', {})
# Slack
slack_webhook = notification_config.get('slack_webhook') or os.environ.get('SLACK_WEBHOOK_URL')
if slack_webhook:
channel = notification_config.get('slack_channel', '#dev-alerts')
self.channels.append(SlackNotification(slack_webhook, channel))
# Discord
discord_webhook = notification_config.get('discord_webhook') or os.environ.get('DISCORD_WEBHOOK_URL')
if discord_webhook:
self.channels.append(DiscordNotification(discord_webhook))
# 데스크톱
if notification_config.get('desktop_enabled', True):
self.channels.append(DesktopNotification())
# 파일 로깅 (항상 활성화)
self.channels.append(FileLogger())
def notify(self, notification_type: NotificationType, message: str,
metadata: Optional[Dict] = None) -> Dict[str, bool]:
"""모든 채널에 알림 전송"""
results = {}
for channel in self.channels:
channel_name = channel.__class__.__name__
results[channel_name] = channel.send(notification_type, message, metadata)
return results
# 전역 매니저
_manager: Optional[NotificationManager] = None
def get_manager() -> NotificationManager:
global _manager
if _manager is None:
_manager = NotificationManager()
return _manager
def on_notification(notification_type: str, message: str,
metadata: Optional[Dict] = None) -> Dict[str, Any]:
"""
알림 훅 메인 함수
Args:
notification_type: 알림 타입 문자열
message: 알림 메시지
metadata: 추가 메타데이터
Returns:
Dict containing notification results
"""
try:
notif_type = NotificationType(notification_type.lower())
except ValueError:
notif_type = NotificationType.INFO
manager = get_manager()
results = manager.notify(notif_type, message, metadata)
logger.info(f"Notification sent: {notif_type.value} - {message[:50]}...")
return {
'notification_type': notif_type.value,
'message': message,
'channel_results': results
}
# 편의 함수들
def notify_info(message: str, **metadata):
return on_notification('info', message, metadata)
def notify_success(message: str, **metadata):
return on_notification('success', message, metadata)
def notify_warning(message: str, **metadata):
return on_notification('warning', message, metadata)
def notify_error(message: str, **metadata):
return on_notification('error', message, metadata)
def notify_security_alert(message: str, **metadata):
return on_notification('security_alert', message, metadata)
def notify_task_complete(message: str, **metadata):
return on_notification('task_complete', message, metadata)
# 테스트
if __name__ == '__main__':
notify_info("테스트 알림입니다", project="모바일프로젝트", task="테스트")
notify_success("작업이 완료되었습니다")
notify_warning("주의가 필요합니다")
notify_error("오류가 발생했습니다")
7. Stop - 작업 완료 검증
핵심 질문
"Claude가 작업을 완료했나, 아니면 계속해야 하나?"
실전 예제
# .claude/hooks/stop.py
"""
작업 완료 검증 및 품질 게이트
"""
import subprocess
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, List, Optional
from .utils import (
load_config, load_json, save_json, run_command, logger
)
class CompletionChecker:
"""작업 완료 검증"""
def __init__(self, task_context: Dict):
self.context = task_context
self.config = load_config()
self.checks_passed = []
self.checks_failed = []
def check_required_files(self, required_files: List[str]) -> bool:
"""필수 파일 존재 확인"""
missing = []
for filepath in required_files:
if not Path(filepath).exists():
missing.append(filepath)
if missing:
self.checks_failed.append({
'check': 'required_files',
'missing': missing,
'message': f"필수 파일 누락: {', '.join(missing)}"
})
return False
self.checks_passed.append('required_files')
return True
def check_tests_pass(self) -> bool:
"""테스트 통과 확인"""
project = self.config.get('project', {})
framework = project.get('framework')
if framework == 'flutter':
result = run_command(['flutter', 'test'], timeout=300)
elif project.get('language') == 'python':
result = run_command(['pytest'], timeout=300)
elif project.get('language') in ['javascript', 'typescript']:
result = run_command(['npm', 'test'], timeout=300)
else:
self.checks_passed.append('tests_skipped')
return True
if result['success']:
self.checks_passed.append('tests')
return True
else:
self.checks_failed.append({
'check': 'tests',
'output': result.get('stderr', result.get('stdout', '')),
'message': '테스트 실패'
})
return False
def check_lint_clean(self) -> bool:
"""린트 오류 없음 확인"""
project = self.config.get('project', {})
framework = project.get('framework')
if framework == 'flutter':
result = run_command(['dart', 'analyze'], timeout=60)
has_errors = 'error' in result.get('stdout', '').lower()
elif project.get('language') == 'python':
result = run_command(['flake8', '--count'], timeout=60)
has_errors = not result['success']
else:
self.checks_passed.append('lint_skipped')
return True
if not has_errors:
self.checks_passed.append('lint')
return True
else:
self.checks_failed.append({
'check': 'lint',
'output': result.get('stdout', ''),
'message': '린트 오류 발견'
})
return False
def check_documentation(self) -> bool:
"""문서화 확인"""
created_files = self.context.get('created_files', [])
# 새 파일이 생성되었는데 README나 문서가 없으면 경고
code_files = [f for f in created_files if Path(f).suffix in ['.dart', '.py', '.js', '.ts']]
doc_files = [f for f in created_files if Path(f).suffix in ['.md', '.txt']]
if len(code_files) >= 3 and len(doc_files) == 0:
self.checks_failed.append({
'check': 'documentation',
'message': '여러 코드 파일이 생성되었지만 문서가 없습니다'
})
return False
self.checks_passed.append('documentation')
return True
def check_no_todos_in_code(self) -> bool:
"""새 코드에 TODO 남아있지 않은지 확인"""
created_files = self.context.get('created_files', [])
files_with_todos = []
for filepath in created_files:
if Path(filepath).exists():
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
if 'TODO:' in content or 'FIXME:' in content:
files_with_todos.append(filepath)
except:
pass
if files_with_todos:
self.checks_failed.append({
'check': 'todos',
'files': files_with_todos,
'message': f"미완료 TODO 발견: {', '.join(files_with_todos)}"
})
return False
self.checks_passed.append('todos')
return True
def generate_task_summary(task_context: Dict) -> str:
"""작업 요약 생성"""
summary_parts = []
# 생성된 파일
created = task_context.get('created_files', [])
if created:
summary_parts.append(f"생성된 파일: {len(created)}개")
for f in created[:5]:
summary_parts.append(f" - {f}")
if len(created) > 5:
summary_parts.append(f" ... 외 {len(created) - 5}개")
# 수정된 파일
modified = task_context.get('modified_files', [])
if modified:
summary_parts.append(f"수정된 파일: {len(modified)}개")
# 실행된 명령어
commands = task_context.get('commands_executed', [])
if commands:
summary_parts.append(f"실행된 명령어: {len(commands)}개")
return '\n'.join(summary_parts)
def on_stop(task_context: Dict[str, Any]) -> Dict[str, Any]:
"""
작업 완료 훅 메인 함수
Args:
task_context: 작업 컨텍스트
- created_files: 생성된 파일 목록
- modified_files: 수정된 파일 목록
- current_task: 현재 작업 설명
- start_time: 시작 시간
Returns:
Dict containing completion status and next steps
"""
logger.info("Checking task completion...")
config = load_config()
stop_config = config.get('hooks', {}).get('stop', {})
checker = CompletionChecker(task_context)
all_passed = True
# 1. 필수 파일 확인 (작업 타입에 따라 다름)
# 실제로는 task_context에서 required_files를 가져와야 함
required_files = task_context.get('required_files', [])
if required_files:
if not checker.check_required_files(required_files):
all_passed = False
# 2. 테스트 통과 확인
if stop_config.get('require_tests', True):
if not checker.check_tests_pass():
all_passed = False
# 3. 린트 확인
if stop_config.get('require_lint', True):
if not checker.check_lint_clean():
all_passed = False
# 4. 문서화 확인
if stop_config.get('require_docs', False):
if not checker.check_documentation():
all_passed = False
# 5. TODO 확인
if stop_config.get('require_no_todos', False):
if not checker.check_no_todos_in_code():
all_passed = False
# 결과 처리
if not all_passed:
# 가장 중요한 실패 이유
primary_failure = checker.checks_failed[0] if checker.checks_failed else {}
logger.warning(f"Task not complete: {primary_failure.get('message', 'Unknown')}")
return {
'complete': False,
'continue': True,
'reason': primary_failure.get('message', '검증 실패'),
'failed_checks': checker.checks_failed,
'passed_checks': checker.checks_passed,
'additional_prompt': _generate_fix_prompt(checker.checks_failed),
'retry_count': task_context.get('retry_count', 0) + 1
}
# 작업 완료
duration = None
if task_context.get('start_time'):
start = datetime.fromisoformat(task_context['start_time'])
duration = str(datetime.now() - start)
summary = generate_task_summary(task_context)
logger.info("Task completed successfully")
# 세션 상태 업데이트
_mark_task_complete(task_context)
return {
'complete': True,
'summary': summary,
'passed_checks': checker.checks_passed,
'metrics': {
'files_created': len(task_context.get('created_files', [])),
'files_modified': len(task_context.get('modified_files', [])),
'duration': duration,
'checks_passed': len(checker.checks_passed)
}
}
def _generate_fix_prompt(failed_checks: List[Dict]) -> str:
"""수정 프롬프트 생성"""
prompts = []
for check in failed_checks:
check_type = check.get('check')
if check_type == 'required_files':
missing = check.get('missing', [])
prompts.append(f"다음 파일들을 생성해주세요: {', '.join(missing)}")
elif check_type == 'tests':
prompts.append("테스트가 실패했습니다. 테스트를 수정하거나 관련 코드를 수정해주세요.")
if check.get('output'):
prompts.append(f"테스트 출력:\n{check['output'][:500]}")
elif check_type == 'lint':
prompts.append("린트 오류를 수정해주세요.")
if check.get('output'):
prompts.append(f"린트 출력:\n{check['output'][:500]}")
elif check_type == 'documentation':
prompts.append("새로 생성된 코드에 대한 문서(README.md 등)를 작성해주세요.")
elif check_type == 'todos':
files = check.get('files', [])
prompts.append(f"다음 파일들의 TODO를 완료해주세요: {', '.join(files)}")
return '\n'.join(prompts)
def _mark_task_complete(task_context: Dict):
"""작업 완료 표시"""
state = load_json('.claude/current_session.json') or {}
if 'completed_tasks' not in state:
state['completed_tasks'] = []
state['completed_tasks'].append({
'task': task_context.get('current_task', 'Unknown'),
'completed_at': datetime.now().isoformat(),
'files_created': task_context.get('created_files', []),
'files_modified': task_context.get('modified_files', [])
})
save_json('.claude/current_session.json', state)
# 테스트
if __name__ == '__main__':
test_context = {
'current_task': '사용자 인증 기능 구현',
'start_time': datetime.now().isoformat(),
'created_files': ['lib/auth/auth_service.dart', 'test/auth/auth_service_test.dart'],
'modified_files': ['lib/main.dart']
}
result = on_stop(test_context)
print(f"결과: {result}")
8-10. 나머지 훅 (요약)
지면 관계상 SubagentStop, PreCompact, SessionEnd는 핵심 코드만 제공합니다.
SubagentStop
# .claude/hooks/subagent_stop.py
def on_subagent_stop(subagent_id: str, task_result: Dict) -> Dict[str, Any]:
"""서브에이전트 작업 검증"""
# 서브에이전트별 검증 로직
validators = {
'ui_builder': validate_ui_output,
'api_integrator': validate_api_integration,
'test_writer': validate_test_coverage,
'docs_writer': validate_documentation
}
validator = validators.get(subagent_id)
if validator:
return validator(task_result)
return {'complete': True}
PreCompact
# .claude/hooks/pre_compact.py
def on_pre_compact(context: Dict) -> Dict[str, Any]:
"""컨텍스트 압축 전 중요 정보 보존"""
# 핵심 정보 추출 및 저장
important_data = {
'decisions': extract_decisions(context),
'current_task': context.get('current_task'),
'files_state': context.get('created_files', []),
'timestamp': datetime.now().isoformat()
}
save_json('.claude/pre_compact_state.json', important_data)
# 압축 후에도 유지할 정보 반환
return {
'preserve': {
'project_context': context.get('readme'),
'coding_standards': context.get('style_guide'),
'current_task': context.get('current_task')
}
}
SessionEnd
# .claude/hooks/session_end.py
def on_session_end(session_data: Dict) -> Dict[str, Any]:
"""세션 종료 시 정리 및 리포트 생성"""
# 1. 세션 통계 계산
statistics = calculate_session_statistics(session_data)
# 2. 마크다운 리포트 생성
report = generate_session_report(session_data, statistics)
report_path = save_report(report, session_data['session_id'])
# 3. Git 자동 커밋 (옵션)
if session_data.get('auto_commit'):
auto_commit(session_data)
# 4. 백업 생성
create_session_backup(session_data)
# 5. 임시 파일 정리
cleanup_temp_files()
# 6. 알림 전송
notify_session_end(statistics)
return {
'report_path': report_path,
'statistics': statistics,
'cleanup_done': True
}
실전 프로젝트 설정 예시
Flutter 프로젝트 (앱)
# .claude/config.yaml
version: "1.0"
project:
name: "프로젝트"
description: "프로젝트"
language: "dart"
framework: "flutter"
hooks:
enabled: true
session_start:
enabled: true
load_readme: true
load_architecture: true
user_prompt_submit:
enabled: true
add_context: true
validate_prompts: true
blocked_keywords:
- "production_api_key"
- "mainnet_private_key"
- "real_wallet"
permission_request:
enabled: true
auto_approve_extensions:
- ".dart"
- ".yaml"
- ".json"
- ".md"
auto_deny_patterns:
- ".env"
- "secrets/"
- "*.key"
pre_tool_use:
enabled: true
block_dangerous: true
rate_limits:
api_call: 30 # per minute
file_write: 50
post_tool_use:
enabled: true
auto_format: true
auto_lint: true
auto_test: false # 수동 트리거
auto_git_stage: true
stop:
enabled: true
require_tests: true
require_lint: true
require_no_todos: false
session_end:
enabled: true
generate_report: true
auto_backup: true
auto_commit: false
security:
sensitive_paths:
- ".env"
- ".env.*"
- "secrets/"
- "private_keys/"
- "*.pem"
- "*.key"
dangerous_commands:
- "rm -rf"
- "sudo"
- "chmod 777"
- "DROP"
- "DELETE FROM"
coding_standards:
architecture: "Clean Architecture"
state_management: "Riverpod 2.5+"
language: "한국어 주석, 영문 dartdoc"
test_coverage: 80
max_file_lines: 300
디렉토리 구조
your_project/
├── .claude/
│ ├── config.yaml # 메인 설정
│ ├── STYLE_GUIDE.md # 코딩 스타일 가이드
│ ├── ARCHITECTURE.md # 아키텍처 문서
│ ├── hooks/
│ │ ├── __init__.py
│ │ ├── utils.py # 공통 유틸리티
│ │ ├── session_start.py
│ │ ├── user_prompt_submit.py
│ │ ├── permission_request.py
│ │ ├── pre_tool_use.py
│ │ ├── post_tool_use.py
│ │ ├── notification.py
│ │ ├── stop.py
│ │ ├── subagent_stop.py
│ │ ├── pre_compact.py
│ │ └── session_end.py
│ ├── logs/
│ │ ├── hooks.log
│ │ ├── audit/
│ │ └── prompts/
│ ├── reports/
│ └── backups/
├── lib/
├── test/
└── pubspec.yaml
마무리
핵심 정리
훅 핵심 질문 주요 용도
| SessionStart | 초기 컨텍스트가 필요한가? | 프로젝트 설정 로드 |
| UserPromptSubmit | 프롬프트 검증/강화가 필요한가? | 보안 검증, 컨텍스트 추가 |
| PermissionRequest | 자동 승인/거부할 것인가? | 권한 자동화 |
| PreToolUse | 도구 실행을 제어할 것인가? | 보안 정책 적용 |
| PostToolUse | 후처리가 필요한가? | 자동 포맷팅, 린트 |
| Notification | 어떻게 알릴 것인가? | Slack/Desktop 알림 |
| Stop | 작업이 완료되었는가? | 품질 게이트 |
| SubagentStop | 서브태스크가 완료되었는가? | 병렬 작업 검증 |
| PreCompact | 보존할 정보가 있는가? | 컨텍스트 백업 |
| SessionEnd | 정리할 것이 있는가? | 리포트 생성, 백업 |
다음 단계
- .claude/ 디렉토리 생성
- config.yaml 설정
- 필요한 훅부터 순차적으로 구현
- 팀 전체에 설정 공유 (Git 커밋)
주의사항
- 훅이 너무 많은 작업을 하면 응답 속도가 느려질 수 있습니다
- 민감한 정보(API 키 등)는 환경 변수로 관리하세요
- 훅 오류가 전체 워크플로우를 막지 않도록 예외 처리를 철저히 하세요
질문이나 피드백이 있으시면 댓글로 남겨주세요!
이 글이 도움이 되셨다면 공유해주세요. 🚀
'AI > Claude code' 카테고리의 다른 글
| Claude Code 훅(Hook) 완벽 가이드: 개발 워크플로우를 자동화하는 11가지 방법 (0) | 2025.11.25 |
|---|---|
| 클로드 코드를 단계별로 배워보자!! (0) | 2025.11.06 |
| Claude Code 실전 활용법 (초급자용) (0) | 2025.11.06 |
| Context-Aware Claude Code: AI 코딩의 숨겨진 슈퍼파워 🚀 (0) | 2025.11.05 |
| 바이브 코딩: 초보자를 위한 완벽 가이드 (0) | 2025.11.04 |
