Claude Code Hooks를 이용한 감사 로그 기록 — 누가, 언제, 무엇을 실행했는지 JSONL로 추적하기
요약
본 글은 Claude Code의 PreToolUse/PostToolUse 후크를 활용하여 AI가 수행하는 모든 도구 실행 과정을 JSONL 형식으로 기록하는 감사 로그 시스템을 소개합니다. 이를 통해 어떤 세션에서, 누가, 언제, 무엇을 실행했는지 추적할 수 있으며, 위험한 명령어 차단과 감사 로깅 기능을 통합할 수 있습니다.
핵심 포인트
- PreToolUse/PostToolUse 후크를 이용해 모든 도구 사용 기록 (JSONL) 가능
- 위험 명령어 차단 및 감사 로그 시스템을 하나의 스크립트로 통합 구현
- 세션 ID, 작업 디렉터리(cwd), 행위자(actor) 등 상세 메타데이터 추적
당사(合同会社ジョインクラス)에서는 Claude Code와 launchd의 17개 작업으로 업무의 대부분을 자동화하고 있습니다. 기사 공개, 영업 파이프라인, 일일 보고서에 이르기까지 사람이 손을 대는 작업은 상당히 줄었습니다.
하지만 그 과정에서 아픈 실패도 했습니다. AI가 git push --force를 자동으로 실행하여 브랜치가 하나 사라진 것입니다. 원인을 조사하려고 해도, '어떤 세션이, 어떤 디렉터리에서, 어떤 명령어를 입력했는지'에 대한 기록이 어디에도 없었습니다. 대화 로그를 수동으로 거슬러 올라갈 수밖에 없었습니다.
그 이후로 저는 'AI에게 일을 맡긴다면, 감사 로그(audit log)를 남기는 것이 전제'라고 생각하게 되었습니다. 이 글에서는 당사에서 실제로 작동하고 있는 Hooks 기반의 감사 로그 시스템을 그대로 복사하여 사용할 수 있는 형태로 소개합니다.
이 글을 통해 할 수 있게 되는 것들:
PreToolUse/PostToolUse후크를 이용해 모든 도구 실행을 JSONL로 기록하기 - 위험한 명령어 차단과 감사 로그를 하나의 스크립트로 통합jq를 사용해 '누가, 언제, 무엇을' 그 자리에서 검색하기 - 후크 자체의 회귀 테스트(regression test) 작성
Claude Code
│ 도구 실행 직전 (stdin에 JSON이 흘러들어옴)
▼
...
핵심은 블록 판정과 기록을 같은 장소에서 수행하는 것입니다. 블록된 작업이야말로 감사(audit)로 가장 보고 싶은 정보이기 때문입니다.
처음에 당사에서 만든 것은, 위험한 명령어를 막으면서 ALLOW / BLOCK를 한 줄씩 출력하는 것에 불과한 후크였습니다. 실제 스크립트에서 발췌합니다.
#!/bin/bash
# PreToolUse: 위험한 Bash 명령어를 블록함
# exit 0 = 허용 / exit 2 = 블록 (stderr가 Claude에 피드백됨)
...
이것으로 재발은 막을 수 있었습니다. 하지만 운영해 보니 감사 로그로서는 부족한 점들이 몇 가지 나왔습니다.
| 문제 | 불편했던 점 |
|---|---|
| Bash 외는 기록되지 않음 | Write / Edit에서 어떤 파일을 수정했는지 추적할 수 없음 |
| 세션 ID가 없음 | 병렬로 작동하는 launchd 작업 중 어느 것이 실행했는지 구별 불가 |
cwd가 없음 | 같은 git push라도, 어느 리포지터리에서의 이야기인지 알 수 없음 |
| 평문 텍스트 | 여러 줄의 명령어가 섞이면 줄이 어긋나서 집계하기 어려움 |
| 결과가 없음 | '허용했다'는 것은 알지만 '성공했는지'는 알 수 없음 |
후크의 stdin에는 도구 이름과 입력 외에 session_id,
・cwd,
・hook_event_name 등이 JSON으로 전달됩니다. 이것을 버리지 않고, 1 이벤트당 1줄의 JSONL로 남기도록 했습니다.
#!/bin/bash
# 감사 로그 공통 처리. pre / post 양쪽 후크에서 source 함
AUDIT_DIR="${CLAUDE_AUDIT_DIR:-$HOME/.claude/logs/audit}"
...
target에는 도구별로 '무엇에 대한 작업인지'를 하나만 넣었습니다. Bash라면 명령어, Write / Edit이라면 파일 경로입니다. 입력 전문(tool_input 전체)은 기록하지 않습니다. Write의 content까지 남기면 로그가 비대해질 뿐더러, .env 내용 같은 비밀 정보가 로그에 복제되어 버리기 때문입니다. actor는 '누가'를 나타냅니다. 사람이 대화에서 사용할 때는 interactive를, launchd에서 시작하는 작업에서는 스크립 측에서 환경 변수를 설정합니다.
# launchd에서 시작하는 스크립트 측 (예: auto-dept-marketing.sh)
export AUDIT_ACTOR="launchd:auto-dept-marketing"
claude -p "$PROMPT" --allowedTools "Bash,Read,Write,Edit"
이것으로 '사람이 했는지, 어느 작업이 했는지'를 한 열로 구별할 수 있게 되었습니다.
#!/bin/bash
set -uo pipefail
source "$(dirname "$0")/audit-lib.sh"
...
PostToolUse는 기록 전용이므로, 어떤 상황에서도 exit 0으로 종료됩니다. 여기서 실패를 반환하더라도, 이미 도구는 실행되었기 때문에 의미가 없습니다.
{
"hooks": {
"PreToolUse": [
...
chmod +x .claude/hooks/audit-*.sh
matcher를 "Bash"가 아닌 "*"로 설정하는 것이 중요합니다. 감사 대상은 Bash에만 국한되지 않습니다.
실제로 하루 동안 실행하면, 다음과 같은 줄들이 나열됩니다 (경로는 일부 가렸습니다).
{"ts":"2026-10-14T01:02:11Z","user":"kyoagun","host":"mac","actor":"launchd:auto-dept-marketing","session":"3f2c…","event":"PreToolUse","cwd":"/Users/.../one-ceo","tool":"Edit","target":".company/departments/marketing/content-performance.md","decision":"ALLOW","note":""}
{"ts":"2026-10-14T01:05:40Z","user":"kyoagun","host":"mac","actor":"interactive","session":"9a17…","event":"PreToolUse","cwd":"/Users/.../one-ceo","tool":"Bash","target":"git push --force origin chore/batch","decision":"BLOCK","note":"git push --force는 금지입니다. --force-with-lease를 사용해 주세요."}
JSONL 형식으로 저장하면, jq만으로 감사 문의가 가능합니다. 현장에서 자주 사용하는 명령어들을 나열합니다.
LOG=~/.claude/logs/audit/$(date +%F).jsonl
# 오늘 차단된 작업 목록
jq -r 'select(.decision=="BLOCK") | [.ts, .actor, .target] | @tsv' "$LOG"
...
마지막 쿼리가 가장 유용합니다. "사고가 발생한 세션에서 직전에 무엇을 하고 있었는지"를 몇 초 만에 알 수 있습니다. 맨 앞에 붙는 --force 같은 사고 때 몇십 분이 걸리던 조사가, 이제는 한 명령어로 끝납니다.
훅(hook)은 "작동한다고 생각했는데 조용히 통과시키는 경우"가 가장 무서우므로, 당사에서는 회귀 테스트를 launchd의 일일 작업으로 실행하고 있습니다. 실제 테스트 스크립트 구조를 그대로 사용하며, 감사 로그가 기록되는지 여부도 확인하도록 했습니다.
#!/bin/bash
set -uo pipefail
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
...
$ ./.claude/hooks/test-audit.sh
7 passed, 0 failed
실제로 운영하면서 겪었던 함정들입니다.
1. jq가 PATH에 없음 (launchd 실행 시)
launchd에서 시작하면 PATH가 최소화되어 Homebrew의 jq를 찾지 못해 훅이 실패합니다. PreToolUse가 실패하면, exit code가 2 이외의 값이 되므로 도구는 그대로 실행되고, 로그만 빠집니다. plist의 EnvironmentVariables에 /opt/homebrew/bin을 포함하는 PATH를 작성해 주세요.
2. 정규 표현식 오탐지
echo "git push --force는 금지"처럼, 문자열로 포함만 하는 명령어도 패턴에 따라 차단될 수 있습니다. 테스트에 "통과해야 할 케이스"를 반드시 넣고, 오탐지를 회귀 테스트로 관리해 주세요.
3. 로그에 비밀 정보를 남기지 않기
tool_input을 통째로 기록하면, Write의 본문이나 curl -H "Authorization: …"의 토큰이 남습니다. 당사에서는 target
만 기록하고, 디렉터리는 chmod 700으로 설정합니다.
Bash 명령어에 토큰이 들어가는 운영이라면, sed로 마스킹한 후 작성해 주세요.
4. 로그의 비대화(肥大化)
matcher: "*"를 사용하면 Read도 모두 기록되므로, 하루에 수천 줄이 됩니다. 날짜별로 파일을 분리하고, 오래된 파일은 주간 작업(weekly job)으로 gzip 처리합니다.
5. 훅(Hook) 변경 사항이 즉시 반영되지 않을 수 있음
settings.json의 훅 정의는 세션 시작 시 로드됩니다. 설정을 변경했다면, /hooks에서 등록 내용을 확인한 후 새로운 세션으로 테스트해 보세요.
- 위의 3개 파일을
.claude/hooks/에 두고,chmod +x를 합니다 -
.claude/settings.json에 훅을 등록합니다 -
./.claude/hooks/test-audit.sh가0 failed이 되는 것을 확인합니다 - Claude Code를 실행하고, "git push --force"를 요청하여 차단되는 것을 확인합니다 -
jq 'select(.decision=="BLOCK")' ~/.claude/logs/audit/$(date +%F).jsonl에 기록이 나오는 것을 확인합니다
자동화를 진행할수록, "AI의 능력을 어떻게 제한할 것인가"라는 이야기가 되기 쉽습니다. 하지만 제 느낌은 조금 다릅니다. 감사 로그나 블록 훅은 안심하고 위임하기 위한 메커니즘이라고 생각합니다.
무슨 일이 생겨도 나중에 추적할 수 있는 상태로 만들어 놓으면, 맡길 범위를 넓힐 수 있습니다. 반대로, 추적할 수 없는 상태에서 권한만 넓히면, 단 한 번의 사고로 모든 것을 멈춰야 합니다. 저희 회사가 승인 파이프라인(draft → 승인 → 실행)과 감사 로그를 세트로 운영하는 이유가 바로 그것입니다.
AI는 실행에 사용하고, 판단은 인간이 합니다. 그 판단의 재료로서, 우선 오늘 두 개의 훅을 넣어보세요.
Hooks・launchd・cron을 조합하여 업무를 자동으로 돌리는 메커니즘은, 『Claude Code 전자동화 바이블』에서 체계적으로 정리했습니다. 이번과 같은 감사/블록 훅을 작업 설계나 오류 시 재시도(retry) 및 알림과 어떻게 결합할지까지 설명하고 있습니다.
책 목록은 여기입니다: https://zenn.dev/joinclass?tab=books
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기