문제는 이것이었다. 내 Claude Code 하네스(harness, AI가 다 했다고 거짓말 못 하게 증거를 강제하는 감시 장치)에는 훅(hook, 도구가 실행되기 전후에 자동으로 끼어들어 위험한 명령을 막거나 규칙 위반을 잡는 작은 검사 스크립트)이 수백 개 걸려 있다. 그런데 에이전트(agent, 역할을 맡은 보조 AI)와 스킬(skill, 정해진 절차를 담은 작업 설명서) 안에서도 그 훅이 실제로 도는지를 알 방법이 없었다. 조사해 보니 답은 "돈다"였다. 다만 조사 도중 더 큰 구멍이 드러났다. 프로젝트 스코프(scope, 훅이 등록된 범위를 뜻한다. 프로젝트 폴더 안의 설정 파일에 걸린 훅이 프로젝트 스코프다)의 훅 605개는 실행돼도 기록이 전혀 남지 않았다. 그래서 기록을 남기는 장치를 복구했다. 그러고 나서야 서브에이전트(subagent, 메인 대화가 일을 나눠 맡기려고 띄우는 보조 AI 세션) 안에서 훅이 실행된 횟수가 처음으로 보였다. 누적 11,502건이었다.
원래 질문은 "에이전트·스킬 스코프 훅 파이프라인이 존재하고 실제로 도는가"였다. 파이프라인(pipeline)이란 훅이 등록되고, 호출되고, 결과가 기록되기까지 이어지는 한 줄의 흐름을 말한다. 답은 둘로 갈린다. 첫째, 전역 훅은 서브에이전트 안에서 확실히 돈다. 발화(훅이 실제로 한 번 실행되는 것)가 누적 11,502건이고, 25종의 에이전트에서 돌았으며, 실제로 작업을 막은 차단이 18건이다. 둘째, 에이전트·스킬마다 다른 훅을 거는 "차등" 기능은 채택이 0이다. 플랫폼(Claude Code 자체)은 이미 세 가지 방식으로 지원하는데, 내 하네스는 그중 하나도 쓰지 않았다. 그리고 이 사실을 확인하려다 프로젝트 스코프 605개 훅이 통째로 측정 밖이라는 별개의 문제가 드러났다.
먼저 분석 문서를 읽지 않은 blind(블라인드, 남의 결론을 모르는 채로 보는 방식) 감사 에이전트 2기를 띄워 디스크의 원본을 독립적으로 다시 세게 했다. 코드에 대한 주장 24항목은 전부 맞았다. 수치는 2건이 정정됐고, 가정 하나는 반증됐다.
래퍼(wrapper, 훅을 감싸서 대신 실행하고 그 실행 기록을 남기는 껍데기 스크립트)를 씌웠다. 정본 파일 4개를 고치고 19개 프로젝트에 롤아웃(rollout, 고친 것을 실제 프로젝트마다 배포하는 일)했다. 이제 훅이 한 번 발화할 때마다 (태그, 스코프, 훅명, exit) 네 값이 한 줄로 남는다. exit는 종료 코드로, 0이면 통과이고 2면 차단이다.
Claude로 돌린 QA(품질 검증)는 4라운드 내내 전건 통과(PASS) 판정이었다. 같은 코드를 다른 모델인 Codex에 넘기자 결함 11건이 나왔다. 등급별로는 치명 CRITICAL 3건, 높음 HIGH 4건, 중간 MEDIUM 4건이다.
두 질문을 분리해야 답이 정확해진다. ①전역 훅이 에이전트 안에서도 도는가와 ②에이전트·스킬마다 다른 훅을 걸 수 있는가는 다른 얘기다. 전역 훅이란 내 계정 전체 설정 파일에 걸려 모든 대화에 똑같이 적용되는 훅이다. ①의 답은 "돈다"이고, ②의 답은 "플랫폼은 되는데 우리가 안 썼다"이다. 그리고 ①을 증명할 수 있게 된 것은 이번 작업 이후다. 그전까지 텔레메트리(telemetry, 훅이 언제 어디서 돌았는지를 자동으로 남기는 기록 장치)는 훅 이름만 남기고 어느 에이전트 안에서 돌았는지를 기록하지 않았다. 도는 걸 알 수 없으니 "없는 것"처럼 보였던 셈이다.
| 차등 방식 | 플랫폼 지원 | 하네스 채택 | 실작동 증거 |
|---|---|---|---|
전역 훅 (계정 설정 파일 settings.json) |
지원. 메인·서브에이전트에 동일하게 적용 | 전면 사용 | 발화 11,502건 · 차단 18건(동적 명령 7 · scaffold(프로젝트 뼈대 규칙) 위반 6 · 세션 탈취 4 · 스킬 스캔 1). 차단은 10개 에이전트에 이름까지 귀속됐다 |
agent frontmatter hooks: (에이전트 정의 파일 머리말에 훅을 직접 선언하는 방식) |
지원 (프로젝트 에이전트는 신뢰 승인 관문 동반) | 0 / 89 | 격리 실험으로 동작 확인, 운영 채택 없음 |
skill frontmatter hooks: (스킬 정의 파일 머리말에 훅을 선언하는 방식) |
지원. 한 번 실행되면 자동으로 풀리는 once: true 옵션은 여기서만 동작한다 |
0 / 555 | 격리 실험으로 동작 확인, 운영 채택 없음 |
Subagent 이벤트의 agent-type matcher (서브에이전트가 시작·종료할 때 어느 에이전트 종류에 훅을 적용할지 고르는 필터) |
지원 | 등록됨, 단 matcher 전부 빈 값 | SubagentStop(서브에이전트 종료 이벤트) 62건 발화. 다만 전 에이전트에 일괄 적용 |
텔레메트리가 훅 이름만 기록하고 어느 에이전트 안이었는지는 남기지 않았다. 그래서 "에이전트 안에서 훅이 돈다"는 사실이
측정에 잡히지 않았고, 갭(gap, 있어야 할 것과 실제 사이의 빈틈)이 갭으로 보이지도 않았다. 이번에 기록 한 줄마다 에이전트 종류를 담는 agent_type 필드와
에이전트 개체 번호를 담는 agent_id 필드를 넣고 나서야 위 수치가 처음 나왔다. 측정을 만들자 질문이 답을 얻은 셈이다.
훅은 이미 돌고 있으므로 남은 과제는 고위험 에이전트에만 더 조이는 차등을 켜는 것이다.
같은 훅인데 보이는 쪽과 안 보이는 쪽이 갈려 있었다. 훅은 두 범위에 등록된다. user 스코프는 내 계정 전체에 적용되는 설정 파일(~/.claude/settings.json)이고, project 스코프는 프로젝트 폴더 안의 설정 파일이다.
user 스코프 훅 중 37개는 텔레메트리 래퍼 hook-run.sh(훅을 대신 실행하고 그 발화를 기록 파일에 한 줄 남기는 스크립트)를 경유해 기록을 남긴다.
그런데 프로젝트 스코프 훅은 605개 전부가 래퍼를 거치지 않았다. 설치기(프로젝트마다 훅을 등록해 주는 스크립트)가 처음부터 래퍼 없이 등록했기 때문이다.
프로젝트 설정 파일에서 래퍼 이름을 세는 명령 grep -c 'hook-run.sh'의 결과가 0이었다.
구현에 들어가기 전에 분석부터 다시 쟀다. 요청은 "타당성을 입증하고 개선안을 짜서 진행하라"였다. 그래서 구현 전에 분석 문서를 읽지 않은 감사 에이전트 2기를 띄워
디스크에서 직접 세게 했다. 한쪽은 수치를 전수 조사했다. 19개 프로젝트의 설정 파일 settings.json을 하나하나 파싱해 훅 개수를 셌다.
다른 쪽은 코드에 대한 주장 24항목을 대조했다. 설치기의 어느 줄이 문제인지, 리포터(보고서를 만드는 스크립트)의 분모에 무엇이 들어가는지, 매처가 어떻게 배선돼 있는지 같은 것들이다.
결론은 분석이 타당했다였다. 다만 그대로는 아니었다.
| 검증 항목 | 분석 문서 | 재실측 | 판정 |
|---|---|---|---|
| project 훅 래퍼 경유 | 0 | 0 / 605 | 확인 핵심 주장 |
| 중복 등록 분포 | 33건 / 5개 프로젝트 | 15+15+1+1+1 완전 일치 | 확인 |
| user 스코프 구성 | 54 = 53 command + 1 http | 정확히 일치 | 확인 |
| 설치기 코드 주장 | 설치기 224번째 줄(L224)에 래퍼 없음 외 24항목 | 전건 일치 | 확인 |
| 영향 프로젝트 수 | 18개 | 19개 (+1) | 정정 심층 중첩 누락 |
| 영향 훅 수 | 582개 | 605개 (+23) | 정정 |
| "설치 프로젝트는 전부 git 저장소" | 손실 커버리지 0 | 3개는 자체 .git 폴더(git 저장소임을 나타내는 폴더)가 없음 | 반증 설계 보정 |
래퍼는 기록마다 프로젝트를 구별하는 태그를 붙이는데, 그 태그는 현재 폴더에서 위로 올라가며 .git 폴더를 찾아 만든다.
자체 .git이 없는 3개 프로젝트는 이 상위 탐색이 전부 홈 디렉토리에서 멈춰 같은 태그를 받았다.
태그가 겹치면 한 프로젝트의 발화가 다른 프로젝트의 죽은 동명 훅을 살아있게 보이게 만든다. 아무 경고도 없는 오판이다.
정지 조건을 프로젝트 설정 파일 .claude/settings.json 마커 우선으로 바꿔 해소했다. 정상 프로젝트의 태그는 그대로 유지됐다.
"설치기 한 줄만 고치면 된다"는 초안은 적대 리뷰(adversarial review, 코드를 일부러 깨뜨리려는 관점으로 검토하는 리뷰)에서 무너졌다. 구조 문제가 세 가지였다. 래퍼가 자기 자신을 등록했다. 설치기가 append-only(기존 등록을 지우지 않고 덧붙이기만 하는 방식)라 같은 훅이 두 번 실행됐다. 리포터 분모에 project 스코프가 없어 다 고쳐도 리포트에 안 잡히는 구조였다. 그래서 배치·조립·마이그레이션(migration, 기존 설정을 새 형식으로 옮기는 작업)·집계 네 지점을 한 단위로 바꿨다.
명령 문자열에 래퍼를 앞에 붙인다
그대로 실행했다면 훅이 이중 실행되거나, 래퍼가 자기 자신을 감싸거나, 다 고쳐도 리포트에 안 잡혔을 것이다.
배치 · 조립 · 치환 · 집계를 한 단위로
래퍼를 glob(별표 같은 이름 패턴으로 파일을 한꺼번에 고르는 방식) 밖에 두어 설치기가 래퍼 자신을 훅으로 줍지 못하게 했다. 등록은 append(덧붙이기) 대신 기존 항목을 지우고 다시 넣는 치환으로 바꿨다. 리포터 분모와 동일성 키도 함께 바꿨다.
Claude로 돌린 QA는 4라운드 내내 전건 통과(PASS)였다. 검사 내용은 결정론 테스트 케이스(TC, 같은 입력이면 항상 같은 결과가 나와야 하는 검사) 13건과
E2E 저니(설치부터 차단 기록까지 실제 흐름을 처음부터 끝까지 따라가는 검사) 6건이었다. 같은 코드를 Codex에 적대 리뷰시키자
CRITICAL 3건, HIGH 4건, MEDIUM 4건, 합쳐 11건이 나왔다. 더 눈여겨볼 것은 그중 2건이 "수정이 만든 새 구멍"이었다는 점이다.
R1부터 R5까지 다섯 번이다(R은 리뷰 라운드 번호). 1~4라운드 동안 위아래 판정이 어긋나고, 5라운드(FIFO, 즉 이름 붙은 파이프 파일 문제의 수정을 다시 리뷰한 회차)에서 처음 함께 clean(결함 없음)이 된다.
표의 라운드 표기는 R1부터 R5까지 리뷰 라운드 번호이고, 그 옆은 결함 등급이다.
치명(CRITICAL)은 강제 계층이 통째로 꺼지거나 남의 파일이 파괴되는 급이다. 높음(HIGH)은 판정을 속이거나 기록을 잃게 하는 급이다.
중간(MEDIUM)은 특정 조건에서만 새는 급이다.
| 라운드 | 결함 | 무엇이 문제였나 | 수정 |
|---|---|---|---|
R1 CRITICAL |
진단 모드가 게이트를 통째로 우회 | 태그 조회용 환경변수가 켜져 있으면 모든 훅이 태그만 찍고 exit 0(통과)으로 끝난다. 에러도 로그도 없이 강제 계층이 멈춘다 | 인자 개수로 교차검증 (인자가 없을 때만 진단 모드) |
R1 CRITICAL |
설치기의 복사 명령 cp가 링크 대상을 파괴 |
하드링크(한 파일 내용에 이름을 하나 더 붙인 것. 한쪽을 덮어쓰면 다른 이름의 파일도 함께 바뀐다)로 훅 자리를 피해자 파일에 미리 연결해 두면, 설치 한 번에 그 파일이 0바이트가 된다 | 프로젝트 안의 모든 쓰기를 temp 파일에 쓴 뒤 rename으로 끼우는 원자 교체(중간 상태가 남지 않는 교체)로 변경 |
R1 HIGH |
훅 이름을 통한 JSON 인젝션(주입) |
기록 파일은 한 줄이 JSON(프로그램이 읽는 데이터 형식) 한 건인데, 훅 파일명에 줄바꿈을 심으면 위조 텔레메트리 레코드를 끼워 넣어 판정을 속일 수 있다 |
필드 새니타이즈 (위험 문자 제거) |
R1 HIGH |
공백 포함 경로가 쪼개짐 | 프로젝트 경로에 공백이 있으면 인자가 분할돼 그 프로젝트가 리포트에서 조용히 누락 | 마커 목록을 배열로 다뤄 공백이 있어도 한 덩어리로 유지 |
R2 HIGH |
설정 파일 동시 갱신 소실 | 원자적 rename은 크래시(중간에 죽음) 안전성일 뿐이다. 두 설치기가 동시에 돌면 나중 쓰기가 앞선 등록을 조용히 삼킨다 | 읽기부터 교체까지 전 구간에 배타 락(한 번에 한 프로세스만 들어가는 잠금). 락을 못 잡으면 진행하지 않는 fail-closed 방식 |
R2 MEDIUM |
제어문자 잔존 | 따옴표·개행 등 4종만 지우면 탭 문자가 남는다. 그 줄은 파싱에 실패하고 레코드가 소리 없이 사라진다 | 제어문자 클래스 전체 제거 |
R2 MEDIUM |
개행 포함 경로 | 경로에 줄바꿈이 있으면 탐색 단계에서 쪼개진다. 공백 수정으로도 닫히지 않은 잔여 경로다 | NUL(문자 코드 0. 파일 이름에 절대 들어갈 수 없는 문자) 구분 탐색 |
R3 CRITICAL |
락 파일이 새 공격면이 됨 | 2라운드에서 추가한 락 파일을 쓰기 모드 open("w")로 열었다. 그러면 락을 얻기도 전에 파일이 이미 truncate(0바이트로 비워짐)된다. 락 자리에 심링크(다른 파일을 가리키는 바로가기)를 심으면 무기가 된다 |
쓰기 플래그 없는 open + 심링크/하드링크/일반파일 3검사 |
R3 HIGH |
보호 범위 과잉 주장 | 락은 협조하는 설치기끼리만 직렬화하는데 주석은 "수동 편집까지 보호"라고 적혀 있었다 | 주장 범위를 코드 주석에서 축소 |
R4 MEDIUM |
락 자리에 FIFO를 두면 설치가 무한 대기 |
FIFO는 이름 붙은 파이프라는 특수 파일로, 열면 상대가 쓸 때까지 기다린다. 락 자리에 이걸 심어두면 열기 단계에서 타임아웃이 걸리기도 전에 영원히 멈춘다 |
기다리지 않는 논블록 열기 + 일반 파일 검사 (구버전은 hang, 즉 멈춤. 신버전은 즉시 거부를 실측) |
R5 MEDIUM |
FIFO 거부에 회귀 테스트(고친 것이 다시 깨지지 않는지 매번 확인하는 테스트)가 없다 |
수정은 수동 재현으로 확인했지만 영속 테스트가 없어 다음 변경에서 조용히 깨질 수 있다 | 미조치. 후속 과제로 기록 (5라운드는 CRITICAL·HIGH 0건) |
1라운드의 하드링크 방어를 넣자 2라운드에서 동시성 축(두 프로세스가 동시에 쓰는 경우)이 비어 있었다. 그걸 락으로 막자 3라운드에서 락 자신이 뚫렸다.
그걸 막자 4라운드에서 FIFO가 남았다. 완화책을 넣을 때마다 그 완화책이 지나가지 않는 경로가 남는다.
원칙을 적어두는 것으로는 부족하고, 분기마다 확인해야 한다.
"고쳤다"와 "적용됐다"는 다르다. 설치는 19개 프로젝트 중 19개에 끝났다. 로컬 커밋(commit, 변경을 저장소 이력에 기록하는 단위)은 4차례(마이그레이션 · 태그 수정 · 마커 갱신 · 보안 수정)에 걸쳐 16개 저장소에 반영했다.
각 커밋은 커밋에 들어간 파일 목록을 보여 주는 명령 git show --name-only로 의도한 .claude 경로만 들어갔는지 확인했다.
홈 디렉토리 자체가 저장소인 3개 프로젝트는 무관한 변경 230만 줄이 널려 있어 이 확인이 특히 중요했다.
나머지 3개는 저장소가 .claude/(또는 .claude/hooks/)를 gitignore(git이 추적하지 않을 경로 목록)로 제외한 곳이다. 파일은 적용됐고, 커밋할 것이 없어 no-op(아무 일도 하지 않음)으로 끝난 것이 정상이다.
래퍼의 지문(파일 내용의 해시값. 한 글자만 달라도 값이 바뀐다)은 정본·템플릿·설치본·미러 네 곳 전부 동일하다. 설정 파일 19개는 전부 유효한 JSON이고, 절대 경로는 0건이다.
차단하던 훅은 래퍼를 거쳐도 그대로 차단한다(종료 코드 exit 2). 관측을 얻으면서 강제력을 잃지 않았다.
구현자의 설명을 받지 않은 blind 검증자가 기준 10개를 디스크에서 직접 실측해 전건 통과했다.
실제 차단 저니(위반 입력을 넣으면 exit 2로 막히고 태그 레코드가 남는지), FIFO와 하드링크 공격 재현, 지문 4자 일치,
커밋된 내용 기준 확인까지 포함한다. 판정 근거는 실제로 돌렸고 기록이 남았다는 사실이다. "돌 것이다"라는 예상은 근거로 치지 않았다.
숨기지 않고 보류 버킷으로 노출
템플릿 관리 밖의 커스텀 훅 5건과 user 스코프에서 아직 래퍼를 안 씌운 훅 16건은 이번 범위가 아니다. 리포터가 이것들을 판정 불가로 분리 집계해 "측정 못 한 것"이 "문제 없음"으로 둔갑하지 않게 했다.
측정은 열렸고, 채택은 아직 0
이번에 연 것은 관측이다. 차등은 아직이다. 그리고 FIFO 거부에 회귀 테스트가 없다(5라운드 MEDIUM, 미조치). 다음 순서는 두 가지다.
① 기록의 agent_type 값을 보고 에이전트별로 검사를 분배하는 훅 하나를 만들어, 89개 에이전트 파일을 손대지 않고 per-agent(에이전트마다 다른) 검사를 거는 것.
② 그다음 고위험 에이전트(배포·사용자 대리)에만 frontmatter 훅을 직접 부여하는 것이다.
매처 사각(훅이 못 보는 도구 노출 8종)과 로그 로테이션(기록 파일이 커지면 잘라서 보관하는 기능) 미작동도 조사만 해둔 상태다.
CRITICAL을 만들었다. 완화책도 리뷰 대상이다PASS 4회 옆에서 다른 모델이 결함 11건을 집었다이 작업의 발단이 된 분석은 이 글이다. 에이전트 스코프 훅은 이미 있었다