Hooks
What claude-forge runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add sangrokjung/claude-forge > /plugin install claude-forge@claude-forge
Ships with claude-forge. Installing the plugin gets these hooks.
What fires, and when
_comment
_v3_events
PreToolUse
- Matches
Bash~/.claude/hooks/remote-command-guard.sh - Matches
mcp__*~/.claude/hooks/rate-limiter.sh~/.claude/hooks/mcp-usage-tracker.sh
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
~/.claude/hooks/context-sync-suggest.sh~/.claude/hooks/forge-update-check.sh
UserPromptSubmit
Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.
~/.claude/hooks/work-tracker-prompt.sh
PostToolUse
~/.claude/hooks/work-tracker-tool.sh~/.claude/hooks/output-secret-filter.sh~/.claude/skills/continuous-learning-v2/hooks/observe.sh- Matches
Edit~/.claude/hooks/code-quality-reminder.sh - Matches
Write~/.claude/hooks/code-quality-reminder.sh - Matches
Edit|Write~/.claude/hooks/security-auto-trigger.sh
Stop
~/.claude/hooks/work-tracker-stop.sh~/.claude/hooks/session-wrap-suggest.sh
TaskCompleted
~/.claude/hooks/task-completed.sh
Where it lives
- hooks/code-quality-reminder.shRunsGitHub
Read the script
#!/bin/bash # code-quality-reminder.sh - PostToolUse Hook (Edit/Write) # 코드 수정 후 품질 체크 리마인더를 stderr로 출력 # Claude에게 셀프 체크를 유도하는 간결한 메시지 # exit 0 필수 (세션 방해 금지) INPUT=$(cat) TOOL_NAME=$(echo "$INPUT" | python3 -c " import sys, json try: d = json.load(sys.stdin) print(d.get('tool_name', '')) except: pass " 2>/dev/null) if [[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" ]]; then exit 0 fi FILE_PATH=$(echo "$INPUT" | python3 -c " import sys, json try: d = json.load(sys.stdin) inp = d.get('tool_input', {}) print(inp.get('file_path', '')) except: pass " 2>/dev/null) # 코드 파일만 대상 (md, txt, json, yaml 등 제외) case "$FILE_PATH" in *.ts|*.tsx|*.js|*.jsx|*.py|*.go|*.rs|*.java|*.rb|*.php|*.swift|*.kt|*.sh) ;; *) exit 0 ;; esac echo "[code-quality] 수정된 파일의 에러 핸들링, 불변성 패턴, 입력 검증을 확인하세요." >&2 exit 0 - hooks/context-sync-suggest.shRunsGitHub
Read the script
#!/bin/bash HOOK_GUARD_SKIP_STDIN=1 source "$HOME/.claude/libs/hook-guard.sh" # context-sync-suggest.sh - SessionStart Hook # 마지막 세션 종료 후 일정 시간이 경과했으면 /context-sync 안내 # OMC session-start.mjs, project-memory-session.mjs와 독립 공존 # exit 0 필수 INPUT=$(cat) MSG=$(echo "$INPUT" | $PYTHON3 -c " import sys, json, os, time from datetime import datetime, timezone, timedelta try: d = json.load(sys.stdin) except: sys.exit(0) sid = d.get('session_id', '') if not sid: sys.exit(0) # buffer.jsonl에서 마지막 session_end 찾기 work_log = os.path.expanduser('~/.claude/work-log/buffer.jsonl') if not os.path.exists(work_log): sys.exit(0) last_end = None try: with open(work_log, 'r') as f: for line in f: try: ev = json.loads(line.strip()) if ev.get('event') == 'session_end': last_end = ev.get('ts', '') except: continue except: sys.exit(0) if not last_end: sys.exit(0) # 4시간 이상 경과 시 제안 try: last_dt = datetime.fromisoformat(last_end) now = datetime.now(timezone(timedelta(hours=9))) gap_hours = (now - last_dt).total_seconds() / 3600 if gap_hours < 4: sys.exit(0) except: sys.exit(0) gap_display = f'{int(gap_hours)}시간' if gap_hours < 48 else f'{int(gap_hours/24)}일' print(f'[Context Sync] 마지막 세션 이후 {gap_display} 경과. /context-sync로 놓친 활동을 확인하세요.') " 2>/dev/null) # 첫 사용자 감지: .forge-onboarded 마커 없으면 /guide 안내 if [[ ! -f "$HOME/.claude/.forge-onboarded" ]]; then if [[ -n "$MSG" ]]; then MSG="$MSG [Claude Forge] 처음이신가요? /guide 로 시작해보세요." else MSG="[Claude Forge] 처음이신가요? /guide 로 시작해보세요." fi fi if [[ -n "$MSG" ]]; then $PYTHON3 -c " import json, sys msg = sys.argv[1] output = {'hookSpecificOutput': {'hookEventName': 'SessionStart', 'additionalContext': msg}} print(json.dumps(output, ensure_ascii=False)) " "$MSG" 2>/dev/null fi exit 0 - hooks/db-guard.shGitHub
- hooks/expensive-mcp-warning.shGitHub
- hooks/forge-update-check.shRunsGitHub
Read the script
#!/bin/bash # forge-update-check.sh - SessionStart Hook # 원격에 claude-forge 새 버전이 있는지 체크하고 /forge-update 제안 # 4시간 쿨다운, 4초 네트워크 타임아웃 # exit 0 필수 INPUT=$(cat) MSG=$(echo "$INPUT" | python3 -c " import sys, json, os, subprocess, time try: d = json.load(sys.stdin) except Exception: sys.exit(0) sid = d.get('session_id', '') if not sid: sys.exit(0) # 메타파일 로드 meta_path = os.path.expanduser('~/.claude/.forge-meta.json') if not os.path.exists(meta_path): sys.exit(0) try: with open(meta_path) as f: meta = json.load(f) except Exception: sys.exit(0) repo_path = meta.get('repo_path', '') if not repo_path: sys.exit(0) # 경로 정규화 (심링크 해제) + 저장소 검증 repo_path = os.path.realpath(repo_path) if not os.path.isdir(os.path.join(repo_path, '.git')): sys.exit(0) # claude-forge 저장소인지 특징 파일로 검증 plugin_json = os.path.join(repo_path, '.claude-plugin', 'plugin.json') if not os.path.isfile(plugin_json): sys.exit(0) # 4시간 쿨다운: ~/.claude/ 하위에 마커 파일 (보안상 /tmp 사용 안 함) check_marker = os.path.expanduser('~/.claude/.forge-update-last-check') if os.path.exists(check_marker): try: with open(check_marker) as f: last_check = float(f.read().strip()) if time.time() - last_check < 14400: # 4시간 sys.exit(0) except (ValueError, IOError): pass # 파싱 실패 시 체크 실행 # git fetch (4초 타임아웃, settings.json의 훅 timeout 5초와 맞춤) try: result = subprocess.run( ['git', '-C', repo_path, 'fetch', 'origin', '--quiet'], capture_output=True, text=True, timeout=4 ) if result.returncode != 0: sys.exit(0) except subprocess.TimeoutExpired: sys.exit(0) except Exception: sys.exit(0) # 기본 브랜치 동적 감지 try: default_branch_ref = subprocess.run( ['git', '-C', repo_path, 'symbolic-ref', 'refs/remotes/origin/HEAD'], capture_output=True, text=True, timeout=2 ).stdout.strip() default_branch = default_branch_ref.replace('refs/remotes/origin/', '') if default_branch_ref else 'main' except Exception: default_branch = 'main' # HEAD vs origin/{default_branch} 비교 try: local_head = subprocess.run( ['git', '-C', repo_path, 'rev-parse', 'HEAD'], capture_output=True, text=True, timeout=2 ).stdout.strip() remote_head = subprocess.run( ['git', '-C', repo_path, 'rev-parse', f'origin/{default_branch}'], capture_output=True, text=True, timeout=2 ).stdout.strip() # 마커 업데이트: 비교 로직이 성공적으로 완료된 후에만 기록 try: with open(check_marker, 'w') as f: f.write(str(time.time())) except Exception: pass if local_head == remote_head: sys.exit(0) current_ver = meta.get('version', '?') print(f'[Claude Forge] 새 업데이트가 있습니다 (현재 v{current_ver}). /forge-update 로 업데이트하세요.') except Exception: sys.exit(0) " 2>/dev/null) if [[ -n "$MSG" ]]; then echo "$MSG" >&2 fi exit 0 - hooks/mcp-usage-tracker.shRunsGitHub
Read the script
#!/bin/bash # MCP Usage Tracker - PreToolUse Hook # Logs MCP tool calls to ~/.claude/mcp-usage.log # # Hook trigger: PreToolUse (all mcp__* tool calls) # Exit codes: 0 = allow (never blocks) # Read tool call JSON from stdin INPUT=$(cat) echo "$INPUT" | python3 -c " import sys, json, os from datetime import datetime, timezone, timedelta try: d = json.load(sys.stdin) except: sys.exit(0) tool = d.get('tool_name', '') if not tool.startswith('mcp__'): sys.exit(0) parts = tool.split('__') server = parts[1] if len(parts) >= 2 else '' session = d.get('session_id', '') or f'{os.getppid()}' kst = timezone(timedelta(hours=9)) ts = datetime.now(kst).isoformat() log = os.path.expanduser('~/.claude/mcp-usage.log') with open(log, 'a') as f: f.write(f'{ts} | {server} | {tool} | {session}\n') " 2>/dev/null exit 0 - hooks/output-secret-filter.shRunsGitHub
Read the script
#!/bin/bash # Output Secret Filter - PostToolUse Hook # 도구 실행 결과에서 시크릿을 감지하여 마스킹 # # Hook trigger: PostToolUse (모든 도구) # Exit codes: 0 = 항상 허용 (출력만 수정) # # 동작 방식: # - stdin으로 도구 실행 결과 JSON 수신 # - tool_result에서 시크릿 패턴 감지 # - 감지 시 마스킹된 결과를 stdout으로 출력 (Claude가 마스킹된 값만 봄) # - 마스킹 발생 시 security.log에 기록 (값 자체는 기록하지 않음) # 원격 세션이 아니면 검사 건너뜀 if [[ -z "${OPENCLAW_SESSION_ID:-}" ]]; then exit 0 fi # stdin에서 JSON 읽기 INPUT=$(cat) # 환경변수로 전달하여 Python에서 처리 export _FILTER_INPUT="$INPUT" export _SECURITY_LOG="$HOME/.claude/security.log" python3 << 'FILTER_SCRIPT' import os import sys import json import re from datetime import datetime input_json = os.environ.get("_FILTER_INPUT", "") security_log = os.environ.get("_SECURITY_LOG", "") if not input_json: sys.exit(0) try: data = json.loads(input_json) except (json.JSONDecodeError, ValueError): sys.exit(0) # tool_result에서 출력 텍스트 추출 tool_result = data.get("tool_result", "") if isinstance(tool_result, dict): # 딕셔너리인 경우 문자열로 변환 tool_result = json.dumps(tool_result, ensure_ascii=False) elif not isinstance(tool_result, str): tool_result = str(tool_result) if not tool_result: sys.exit(0) # 마스킹 패턴 정의 (패턴, 설명) SECRET_PATTERNS = [ # API 키 패턴 (r'\bsk-[a-zA-Z0-9_-]{20,}\b', "OpenAI API Key"), (r'\bsk-proj-[a-zA-Z0-9_-]{20,}\b', "OpenAI Project Key"), (r'\bAKIA[A-Z0-9]{16,}\b', "AWS Access Key"), (r'\bxoxb-[a-zA-Z0-9-]{20,}\b', "Slack Bot Token"), (r'\bxoxp-[a-zA-Z0-9-]{20,}\b', "Slack User Token"), (r'\bghp_[a-zA-Z0-9]{36,}\b', "GitHub PAT"), (r'\bghs_[a-zA-Z0-9]{36,}\b', "GitHub App Token"), (r'\bgho_[a-zA-Z0-9]{36,}\b', "GitHub OAuth Token"), (r'\bghu_[a-zA-Z0-9]{36,}\b', "GitHub User Token"), (r'\bglpat-[a-zA-Z0-9_-]{20,}\b', "GitLab PAT"), (r'\bnpm_[a-zA-Z0-9]{36,}\b', "NPM Token"), # Bearer/Auth 토큰 (r'(?i)\bBearer\s+[a-zA-Z0-9_.-]{20,}\b', "Bearer Token"), (r'(?i)\btoken=[a-zA-Z0-9_.-]{20,}\b', "Token Parameter"), (r'(?i)\bauth=[a-zA-Z0-9_.-]{20,}\b', "Auth Parameter"), (r'(?i)\bapi[_-]?key=[a-zA-Z0-9_.-]{20,}\b', "API Key Parameter"), # 비밀번호/시크릿 패턴 (r'(?i)\bpassword=[^\s&]{8,}\b', "Password Parameter"), (r'(?i)\bpasswd=[^\s&]{8,}\b', "Password Parameter"), (r'(?i)\bsecret=[^\s&]{20,}\b', "Secret Parameter"), # 환경변수 값 (KEY=VALUE 형식에서 값 부분) (r'(?i)\bAWS_SECRET_ACCESS_KEY=[^\s]{20,}\b', "AWS Secret Key"), (r'(?i)\bOPENAI_API_KEY=[^\s]{20,}\b', "OpenAI Key Value"), (r'(?i)\bANTHROPIC_API_KEY=[^\s]{20,}\b', "Anthropic Key Value"), (r'(?i)\bTELEGRAM_BOT_TOKEN=[^\s]{20,}\b', "Telegram Token Value"), (r'(?i)\bGITHUB_TOKEN=[^\s]{20,}\b', "GitHub Token Value"), (r'(?i)\bSUPABASE_SERVICE_ROLE_KEY=[^\s]{20,}\b', "Supabase Key Value"), (r'(?i)\bDATABASE_URL=[^\s]{20,}\b', "Database URL Value"), # Private Key 블록 (r'-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----', "Private Key"), # 긴 Base64 같은 문자열 (40자 이상 연속 영숫자, 컨텍스트 의존) (r'(?i)(?:key|secret|token|password|credential|auth)[\s=:]+["\']?[a-zA-Z0-9+/]{40,}={0,2}["\']?', "Potential Base64 Secret"), ] import base64 import urllib.parse def decode_layers(text): """base64, URL 인코딩을 디코딩하여 숨겨진 시크릿을 탐지""" decoded_variants = [] # base64 디코딩 시도 # base64로 보이는 청크를 찾아서 디코딩 b64_pattern = re.compile(r'[A-Za-z0-9+/]{20,}={0,2}') for m in b64_pattern.finditer(text): try: decoded = base64.b64decode(m.group(0), validate=True).decode("utf-8", errors="ignore") if decoded and len(decoded) >= 10: decoded_variants.append(decoded) except Exception: pass # URL 디코딩 시도 try: url_decoded = urllib.parse.unquote(text) if url_decoded != text: decoded_variants.append(url_decoded) except Exception: pass return decoded_variants def mask_match(original): """매칭된 문자열을 마스킹""" if len(original) > 16: return original[:8] + "***MASKED***" + original[-4:] return original[:4] + "***MASKED***" masked_output = tool_result masked_count = 0 masked_types = [] # 1단계: 원본 텍스트에서 직접 매칭 for pattern, desc in SECRET_PATTERNS: matches = list(re.finditer(pattern, masked_output)) if matches: for match in reversed(matches): original = match.group(0) masked_output = masked_output[:match.start()] + mask_match(original) + masked_output[match.end():] masked_count += 1 if desc not in masked_types: masked_types.append(desc) # 2단계: 인코딩 우회 탐지 (디코딩된 텍스트에서 시크릿 발견 시 인코딩된 원본 청크를 마스킹) decoded_variants = decode_layers(tool_result) for decoded_text in decoded_variants: for pattern, desc in SECRET_PATTERNS: if re.search(pattern, decoded_text): # 디코딩된 텍스트에서 시크릿 발견 → 원본의 base64/URL 인코딩 청크를 마스킹 b64_pattern = re.compile(r'[A-Za-z0-9+/]{20,}={0,2}') for m in b64_pattern.finditer(masked_output): try: d = base64.b64decode(m.group(0), validate=True).decode("utf-8", errors="ignore") if re.search(pattern, d): chunk = m.group(0) masked_output = masked_output[:m.start()] + mask_match(chunk) + masked_output[m.end():] masked_count += 1 if desc not in masked_types: masked_types.append(desc) break except Exception: pass # URL 인코딩 우회 탐지 try: url_decoded = urllib.parse.unquote(masked_output) if url_decoded != masked_output and re.search(pattern, url_decoded): # URL 인코딩된 시크릿 → %XX 시퀀스를 포함한 구간 마스킹 pct_pattern = re.compile(r'(?:%[0-9A-Fa-f]{2}[A-Za-z0-9_.~-]*){5,}') for pm in reversed(list(pct_pattern.finditer(masked_output))): decoded_chunk = urllib.parse.unquote(pm.group(0)) - hooks/rate-limiter.shRunsGitHub
Read the script
#!/bin/bash # Rate Limiter - PreToolUse Hook (경량화 버전) # Python → bash 산술 전환, atomic write # # Hook trigger: PreToolUse (mcp__*) # Exit codes: 0 = 허용, 2 = 차단 (속도 제한 초과) # # 제한 (로컬): 분당 60, 시간당 1000, 일 8000 # 제한 (원격): 분당 30, 시간당 500, 일 5000 # 세션 ID 결정 if [[ -n "${OPENCLAW_SESSION_ID:-}" ]]; then SESSION_ID="${OPENCLAW_SESSION_ID}" LIMIT_MIN=30; LIMIT_HOUR=500; LIMIT_DAY=5000 else SESSION_ID="local-${PPID}" LIMIT_MIN=60; LIMIT_HOUR=1000; LIMIT_DAY=8000 fi # 타임스탬프 파일 (/tmp로 전환 — 디스크 I/O 감소) RATE_FILE="/tmp/claude-rate-limits-${SESSION_ID}.log" SECURITY_LOG="$HOME/.claude/security.log" NOW=$(date +%s) MIN_AGO=$((NOW - 60)) HOUR_AGO=$((NOW - 3600)) DAY_AGO=$((NOW - 86400)) # 오래된 항목 정리 + 카운팅 (1회 순회) COUNT_MIN=0 COUNT_HOUR=0 COUNT_DAY=0 CLEANED="" if [[ -f "$RATE_FILE" ]]; then while IFS= read -r ts; do # 숫자가 아니면 건너뜀 [[ "$ts" =~ ^[0-9]+$ ]] || continue if (( ts > DAY_AGO )); then CLEANED="${CLEANED}${ts}\n" (( COUNT_DAY++ )) if (( ts > HOUR_AGO )); then (( COUNT_HOUR++ )) if (( ts > MIN_AGO )); then (( COUNT_MIN++ )) fi fi fi done < "$RATE_FILE" fi # 제한 검사 BLOCKED="" if (( COUNT_MIN >= LIMIT_MIN )); then BLOCKED="분당 속도 제한 초과 (${COUNT_MIN}/${LIMIT_MIN})" LIMIT_TYPE="per_minute" elif (( COUNT_HOUR >= LIMIT_HOUR )); then BLOCKED="시간당 속도 제한 초과 (${COUNT_HOUR}/${LIMIT_HOUR})" LIMIT_TYPE="per_hour" elif (( COUNT_DAY >= LIMIT_DAY )); then BLOCKED="일일 속도 제한 초과 (${COUNT_DAY}/${LIMIT_DAY})" LIMIT_TYPE="per_day" fi if [[ -n "$BLOCKED" ]]; then echo "BLOCKED: $BLOCKED" >&2 # 정리만 저장 (차단된 요청은 카운트 안 함) printf '%b' "$CLEANED" > "${RATE_FILE}.tmp" && mv "${RATE_FILE}.tmp" "$RATE_FILE" # 보안 로그 if [[ -n "$SECURITY_LOG" ]]; then echo "$(date '+%Y-%m-%d %H:%M:%S') | RATE_LIMITED | type=${LIMIT_TYPE} | session=${SESSION_ID}" >> "$SECURITY_LOG" 2>/dev/null fi exit 2 fi # 현재 타임스탬프 추가 + 정리된 데이터 저장 (atomic write) printf '%b' "${CLEANED}${NOW}\n" > "${RATE_FILE}.tmp" && mv -f "${RATE_FILE}.tmp" "$RATE_FILE" exit 0 - hooks/remote-command-guard.shRunsGitHub
- hooks/security-auto-trigger.shRunsGitHub
- hooks/session-wrap-suggest.shRunsGitHub
- hooks/task-completed.shRunsGitHub
- hooks/work-tracker-prompt.shRunsGitHub
- hooks/work-tracker-stop.shRunsGitHub
- hooks/work-tracker-tool.shRunsGitHub
All 15 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Supercharge Claude Code with 11 AI agents, 36 commands & 15 skills — the claude-code plugin framework inspired by oh-my-zsh. 6-layer security hooks included. 5-min install.
Repo: sangrokjung/claude-forge

