질문은 하나였다 — 에이전트·스킬 스코프 훅 파이프라인은 존재하는가, 그리고 실제로 도는가. 답은 "돈다"였다. 다만 그걸 아는 방법이 없었고, 조사 도중 프로젝트 스코프 605개 훅이 통째로 측정 밖이라는 게 드러났다. 관측을 복구하고 나서야 서브에이전트 안의 발화가 처음으로 보였다 — 누적 11,502건.
원래 질문은 "에이전트·스킬 스코프 훅 파이프라인이 존재하고 실제로 도는가"였다. 답은 둘로 갈린다 — 전역 훅은 서브에이전트 안에서 확실히 돈다(누적 발화 11,502건, 25개 에이전트, 실제 차단 18건). 반면 에이전트·스킬마다 다른 훅을 거는 "차등" 기능은 채택이 0이다. 플랫폼은 이미 세 가지 방식으로 지원하는데 하네스가 하나도 쓰지 않았다. 그리고 이 사실을 확인하려다 프로젝트 스코프 605개 훅이 통째로 측정 밖이라는 별개의 문제가 드러났다.
문서를 모르는 blind 감사 2기가 디스크에서 독립 재실측. 코드 주장 24항목 전건 확인, 수치 정정 2건, 반증 1건.
정본 4파일 수정 + 19개 프로젝트 롤아웃. 훅 발화가 (태그, 스코프, 훅명, exit)으로 남는다.
Claude QA는 4라운드 내내 전건 PASS. 같은 코드에서 Codex가 CRITICAL 3·HIGH 4·MEDIUM 4를 적발.
두 질문을 분리해야 답이 정확해진다. ①전역 훅이 에이전트 안에서도 도는가와 ②에이전트·스킬마다 다른 훅을 걸 수 있는가는 다른 얘기다. 전자는 "돈다"이고, 후자는 "플랫폼은 되는데 우리가 안 썼다"이다. 그리고 ①을 증명할 수 있게 된 것은 이번 작업 이후다 — 그전까지 텔레메트리는 훅 이름만 남기고 어느 에이전트 안에서 돌았는지를 기록하지 않았다. 도는 걸 알 수 없으니 "없는 것"처럼 보였던 셈이다.
| 차등 방식 | 플랫폼 지원 | 하네스 채택 | 실작동 증거 |
|---|---|---|---|
| 전역 훅 (settings.json) | 지원 — 메인·서브에이전트 동일 | 전면 사용 | 발화 11,502건 · 차단 18건(동적명령 7·scaffold 6·세션탈취 4·스킬스캔 1), 10개 에이전트에 귀속 |
agent frontmatter hooks: |
지원 (프로젝트 에이전트는 신뢰 승인 관문 동반) | 0 / 89 | 격리 실험으로 동작 확인, 운영 채택 없음 |
skill frontmatter hooks: |
지원 — once: true(1회 후 자동 해제)는 여기서만 동작 |
0 / 555 | 격리 실험으로 동작 확인, 운영 채택 없음 |
| Subagent 이벤트 agent-type matcher | 지원 | 등록됨, 단 matcher 전부 빈 값 | SubagentStop 62건 발화 — 다만 전 에이전트 일괄 |
텔레메트리가 훅 이름만 기록하고 어느 에이전트 안이었는지는 남기지 않았다. 그래서 "에이전트 안에서 훅이 돈다"는 사실이
측정에 잡히지 않았고, 갭이 갭으로 보이지도 않았다. 이번에 agent_type·agent_id 필드를 넣고 나서야
위 수치가 처음 나왔다 — 측정을 만들자 질문이 답을 얻은 셈이다. 남은 과제는 "돌게 하는 것"이 아니라
고위험 에이전트에만 더 조이는 차등을 켜는 것이다.
user 스코프(~/.claude/settings.json) 훅 중 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 | 정확히 일치 | 확인 |
| 설치기 코드 주장 | L224 래퍼 없음 외 24항목 | 전건 일치 | 확인 |
| 영향 프로젝트 수 | 18개 | 19개 (+1) | 정정 심층 중첩 누락 |
| 영향 훅 수 | 582개 | 605개 (+23) | 정정 |
| "설치 프로젝트는 전부 git 저장소" | 손실 커버리지 0 | 3개는 자체 .git 없음 | 반증 설계 보정 |
자체 .git이 없는 3개 프로젝트는 상위 탐색이 전부 홈 디렉토리에서 멈춰 같은 태그를 받았다.
태그가 겹치면 한 프로젝트의 발화가 다른 프로젝트의 죽은 동명 훅을 살아있게 만든다 — 무음 오판이다.
정지 조건을 .claude/settings.json 마커 우선으로 바꿔 해소했고, 정상 프로젝트 태그는 그대로 유지됐다.
"설치기 한 줄만 고치면 된다"는 초안은 적대 리뷰에서 무너졌다 — 래퍼가 자기 자신을 등록하고, 설치기가 append-only라 같은 훅이 두 번 실행되고, 리포터 분모에 project가 없어 다 고쳐도 리포트에 안 잡히는 구조였다. 그래서 배치·조립·마이그레이션·집계 네 지점을 한 단위로 바꿨다.
명령 문자열에 래퍼를 앞에 붙인다
그대로 실행했다면 훅이 이중 실행되거나, 래퍼가 자기 자신을 감싸거나, 다 고쳐도 리포트에 안 잡혔을 것이다.
배치 · 조립 · 치환 · 집계를 한 단위로
래퍼를 glob 밖에 두고, append가 아니라 치환하고, 리포터 분모와 동일성 키를 함께 바꿨다.
Claude로 돌린 QA(결정론 TC 13건 + E2E 저니 6건)는 4라운드 내내 전건 PASS였다. 같은 코드를 Codex에 적대 리뷰시키자 CRITICAL 3 · HIGH 4 · MEDIUM 4, 합쳐 11건이 나왔다. 더 눈여겨볼 것은 그중 2건이 "수정이 만든 새 구멍"이었다는 점이다.
| 라운드 | 결함 | 무엇이 문제였나 | 수정 |
|---|---|---|---|
| R1 CRITICAL | 진단 모드가 게이트를 통째로 우회 | 태그 조회용 환경변수가 켜져 있으면 모든 훅이 태그만 찍고 exit 0 — 에러도 로그도 없이 강제 계층 정지 | 인자 개수로 교차검증 (무인자일 때만 진단) |
| R1 CRITICAL | 설치기 cp가 링크 대상을 파괴 |
훅 자리를 피해자 파일 하드링크로 선점하면 설치 한 번에 그 파일이 0바이트 | 프로젝트 내 모든 쓰기를 temp + rename 원자 교체 |
| R1 HIGH | 훅명 JSON 인젝션 | 훅 파일명에 개행을 심으면 위조 텔레메트리 레코드를 주입해 판정을 속일 수 있다 | 필드 새니타이즈 |
| R1 HIGH | 공백 포함 경로가 쪼개짐 | 프로젝트 경로에 공백이 있으면 인자가 분할돼 그 프로젝트가 리포트에서 조용히 누락 | 마커 목록 배열화 |
| R2 HIGH | 설정 파일 동시 갱신 소실 | 원자적 rename은 크래시 안전성일 뿐 — 두 설치기가 겹치면 나중 쓰기가 앞선 등록을 조용히 삼킴 | 읽기~교체 전 구간 배타 락 (fail-closed) |
| R2 MEDIUM | 제어문자 잔존 | 따옴표·개행 4종만 지우면 탭이 남아 그 줄이 파싱 실패 → 레코드 무음 소실 | 제어문자 클래스 전체 제거 |
| R2 MEDIUM | 개행 포함 경로 | 경로에 개행이 있으면 탐색 단계에서 분할 — 공백 수정으로도 안 닫힌 잔여 경로 | NUL 구분 탐색 |
| R3 CRITICAL | 락 파일이 새 공격면이 됨 | R2에서 추가한 락을 open("w")로 열어 락 획득 전에 이미 truncate — 심링크로 무기화 가능 |
쓰기 플래그 없는 open + 심링크/하드링크/일반파일 3검사 |
| R3 HIGH | 보호 범위 과잉 주장 | 락은 협조하는 설치기끼리만 직렬화하는데 주석은 "수동 편집까지 보호"라고 적혀 있었다 | 주장 범위를 코드 주석에서 축소 |
| R4 MEDIUM | 락 자리 FIFO → 설치 무한 대기 | 파이프를 심어두면 열기 단계에서 타임아웃 이전에 영원히 블록 | 논블록 + 일반파일 검사 (구버전 hang, 신버전 즉시 거부 실측) |
| R5 MEDIUM | FIFO 거부에 회귀 테스트가 없다 | 수정은 수동 재현으로 확인했지만 영속 테스트가 없어 다음 변경에서 조용히 깨질 수 있다 | 미조치 — 후속 과제로 기록 (R5는 CRITICAL·HIGH 0) |
R1의 하드링크 방어를 넣자 R2에서 동시성 축이 비어 있었고, 그걸 락으로 막자 R3에서 락 자신이 뚫렸고, 그걸 막자 R4에서 FIFO가 남았다. 완화책을 넣을 때마다 그 완화책이 지나가지 않는 경로가 남는다 — 원칙을 적어두는 것으로는 부족하고, 분기마다 확인해야 한다.
"고쳤다"와 "적용됐다"는 다르다. 설치는 19/19, 로컬 커밋은 4차례(마이그레이션 · 태그 수정 · 마커 갱신 · 보안 수정)에 걸쳐 16개 저장소에 반영했고,
각 커밋은 git show --name-only로 의도한 .claude 경로만 들어갔는지 확인했다.
홈 디렉토리가 저장소인 3개 프로젝트는 무관한 변경 230만 줄이 널려 있어 특히 중요했다.
나머지 3개는 저장소가 .claude/(또는 .claude/hooks/)를 gitignore로 제외 — 파일은 적용, 커밋은 정상 no-op.
래퍼 지문은 정본·템플릿·설치본·미러 전부 동일. 설정 파일 19개 전수 유효 JSON, 절대 경로 0.
차단하던 훅은 래퍼를 거쳐도 그대로 차단(exit 2). 관측을 얻으면서 강제력을 잃지 않았다.
구현자 서술을 받지 않은 blind 검증자가 기준 10개를 디스크에서 직접 실측해 전건 통과했다 — 실제 차단 저니(위반 입력 → exit 2 + 태그 레코드), FIFO/하드링크 공격 재현, 지문 4자 일치, 커밋된 내용 기준 확인까지. "돌 것이다"가 아니라 돌렸고 기록이 남았다가 판정 근거다.
숨기지 않고 보류 버킷으로 노출
템플릿 관리 밖 커스텀 훅 5건과 user 스코프 미래핑 16건은 이번 범위가 아니다. 리포터가 판정 불가로 분리 집계해 "측정 못 한 것"이 "문제 없음"으로 둔갑하지 않게 했다.
측정은 열렸고, 채택은 아직 0
이번에 연 것은 관측이지 차등이 아니다. 그리고 FIFO 거부에 회귀 테스트가 없다(R5 MEDIUM, 미조치). 다음 순서는 ① agent_type으로 분배하는 훅 하나로
89개 에이전트 파일을 손대지 않고 per-agent 검사를 거는 것, ② 그다음 고위험 에이전트(배포·사용자 대리)에만
frontmatter 훅을 직접 부여하는 것이다. 매처 사각(노출 8종)과 로그 로테이션 미작동도 조사만 해둔 상태다.
이 작업의 발단이 된 분석 — 에이전트 스코프 훅은 이미 있었다 — 채택되지 않았을 뿐