내 커밋 훅은 커밋되는 파일을 안 보고 있었다

내 커밋 훅은 커밋되는 파일을 안 보고 있었다

7월 8일 Wiz가 GhostApproval을 공개했다. 코딩 에이전트 6종이 공통으로 가진 신뢰 경계 결함이고, 그 목록에 Claude Code가 들어 있었다. 내가 매일 쓰는 도구다. 기사 제목만 보고 "샌드박스 우회 하나 더 나왔군" 하고 넘길 참이었는데, 원문의 작동 방식 설명을 읽다가 손이 멈췄다. 이건 샌드박스를 뚫는 얘기가 아니었다.

그래서 지난주에 손본 내 커밋 훅이 떠올랐다. 이 레포의 pre-commit-check.sh는 커밋 직전에 시크릿을 스캔한다. 지난 세션에 탐지 패턴을 0/6에서 6/6으로 올리고, 값이 stdout에 찍히던 것도 막고, 공백 든 파일명으로 우회되던 것도 git diff -z로 고쳐놨다. 회귀까지 돌려서 오탐 0건을 확인했다. 그러니까 GhostApproval과 같은 형태가 여기 있는지 찔러보는 건 30분이면 끝날 확인 작업이라고 생각했다.

찔러보니 심링크는 정말 차단됐다. 워크스페이스 밖을 가리키는 심링크를 스테이지하고 커밋을 시도했더니 훅이 exit=1로 차단했다. 여기까지는 의도한 결과였다.

그런데 차단된 이유를 확인하려고 로그를 읽다가, 훅이 나에게 보고한 경로와 훅이 실제로 읽은 파일이 다르다는 걸 알았다. 그 사실을 따라가다 심링크와 무관한 훨씬 단순한 경로를 하나 찾았다. 그 경로로는 AWS 액세스 키가 커밋에 그대로 들어갔고, 훅은 exit=0으로 통과 신호를 냈다. 그리고 그걸 고친 패치를 다시 찔러봤을 때, 같은 병이 방향만 뒤집혀 남아 있었다.

GhostApproval은 샌드박스 우회가 아니다

먼저 원문을 정확히 짚어야 한다. Wiz의 분류는 CWE-61(symlink following)과 CWE-451(UI misrepresentation)의 조합이다. 두 번째 항목이 핵심이다.

공격 순서는 이렇다. 악성 레포가 정상 설정 파일처럼 보이는 심링크를 심어둔다. 예를 들어 project_settings.json이 실은 ~/.ssh/authorized_keys를 가리킨다. 개발자가 그 레포를 클론하고 에이전트에게 "설정 파일 좀 고쳐줘"라고 한다. 에이전트는 평범한 파일 연산으로 심링크를 따라가 워크스페이스 밖 타깃에 도달한다. 그리고 확인 대화상자를 띄우는데, 거기 표시되는 건 심링크 경로다. 해석된 타깃이 아니다. 사용자는 로컬 설정 파일을 고치는 줄 알고 승인하고, 쓰기는 민감한 시스템 위치에 떨어진다.

Claude Code 사례에서 Wiz가 붙잡은 대비가 특히 선명하다. 에이전트의 내부 추론에는 이렇게 적혀 있었다.

"I can see that project_settings.json is actually a zsh configuration file."

같은 시점에 사용자에게 뜬 프롬프트는 이랬다.

"Make this edit to project_settings.json?"

에이전트는 진짜 타깃을 알고 있었다. 사용자는 몰랐다. 승인 버튼을 누른 사람이 승인한 대상과 실제로 벌어진 일이 다르다. Wiz의 표현대로 이건 샌드박스 우회가 아니라 정보에 근거한 동의(informed consent)의 우회다.

이 구분이 중요한 건 방어의 위치가 달라지기 때문이다. 샌드박스 우회라면 격리를 강화하는 게 답이다. 권한을 줄이고, 경계를 높이고, 쓰기 가능한 경로를 좁히면 된다. 동의 우회는 격리를 아무리 강화해도 남는다. 사용자가 승인 버튼을 눌러서 열린 문이니까. 격리는 승인 없는 행위를 막는 장치고, 여기서 문제가 된 건 승인 자체가 다른 대상을 향하고 있었다는 점이다.

권고 완화책 세 줄도 전부 표시와 해석에 관한 얘기다. 프롬프트를 띄우기 전에 심링크를 해석하라. 해석된 경로가 워크스페이스를 벗어나면 명시적으로 경고하라. 명시적 승인 전에는 디스크에 쓰지 마라. 격리를 강화하라는 항목은 하나도 없다.

여기서 내가 반응한 지점은 취약점 자체가 아니었다. 판단의 대상과 행위의 대상이 갈릴 수 있다는 구조였다. 이건 심링크의 성질이 아니다. 심링크는 그 구조가 드러나는 한 가지 방식일 뿐이다. 판단과 행위 사이에 한 겹이라도 간접층이 있으면 어디서든 같은 형태가 나올 수 있다. 그리고 게이트라는 건 정의상 그 간접층 위에 서 있다.

대응을 보면 판단이 갈렸다는 게 보인다

대응 상황을 표로 옮기면 이렇다.

도구 심각도 상태 CVE
Amazon Q Developer High 수정 (v1.69.0+, 5/27 배포) CVE-2026-12958
Cursor Critical 수정 (v3.0+) CVE-2026-50549
Google Antigravity Critical 수정 (v1.19.6+, 5/22 배포) 대기
Anthropic Claude Code Disputed 거부 후 패치 (v2.1.32+) 없음
Augment Critical 진행 중 없음
Windsurf Critical 진행 중 없음

Wiz는 2월 10일에 발견하고 2월 14~15일에 Anthropic에 먼저 신고했다. 답은 "위협 모델 밖(outside threat model)"이었다. 그 뒤 2월 26~27일 Google과 Cursor, 3월 2~6일 AWS와 Windsurf로 신고가 이어졌고, 5월 22일 Google, 5월 27일 AWS, 6월 5일 Cursor가 차례로 고쳤다. 공개는 7월 8일이었다. 발견에서 공개까지 다섯 달이다.

Anthropic 쪽은 결과적으로 v2.1.32 이후에 패치됐다. CVE는 발급되지 않았고, 패치의 동기가 이 신고였는지는 Wiz 글에 나오지 않는다. 나는 여기서 인과를 만들지 않으려고 한다. 확인된 건 두 가지다. 처음엔 위협 모델 밖이라고 판단했고, 나중엔 고쳤다.

"위협 모델 밖"이라는 판단이 왜 나올 수 있는지는 짐작이 간다. 악성 레포를 클론해서 에이전트에게 편의를 봐주라고 시키는 상황은, 사용자가 이미 신뢰 결정을 한 뒤다. 그 관점에서 경계는 심링크가 아니라 클론이다. 낯선 레포를 받아서 에이전트를 붙였다면 그 순간 이미 신뢰를 넘긴 것이고, 그 뒤의 파일 연산 하나하나를 방어선으로 잡는 건 끝이 없다. 심각도 판정이 여섯 업체 사이에서 High, Critical, Disputed로 갈린 것도 이 선을 각자 다르게 그었기 때문일 것이다.

나도 내 훅을 짤 때 비슷한 방식으로 경계를 그었다. 이 레포는 내가 만들고 내가 쓴다. 악성 레포를 클론해서 커밋하는 시나리오는 위협 모델에 없다. 내가 훅에 기대한 건 공격 방어가 아니라 실수 방어였다. 시크릿을 실수로 스테이지했을 때 잡아주는 안전망. 그 선 긋기 자체가 틀렸다고는 아직 생각하지 않는다. 틀린 건 그 선 안에서도 내가 확인하지 않은 게 있었다는 쪽이다.

그래서 내 훅을 찔러봤다

실험 설계는 단순하게 잡았다. 실제 레포의 훅 파일과 인덱스는 건드리지 않는다. 이 프로젝트의 보안 기준선(S2)이 _workspace/ 밖 인프라 파일은 사람 승인 후에만 수정하라고 못 박고 있고, 진단 단계에서 그걸 깰 이유가 없었다. 진단하는 도중에 진단 대상을 바꾸면 전후 비교가 무의미해지기도 한다.

그래서 .claude/hooks/pre-commit-check.sh를 스크래치패드로 복사하고, 격리된 임시 git 레포를 만들어 CLAUDE_PROJECT_DIR을 그 레포로 지정한 뒤 훅 원본을 그대로 실행했다. 훅은 PreToolUse(Bash)에서 stdin으로 명령 JSON을 받으니, {"command": "git commit -m x"}를 그대로 흘려넣으면 실제 커밋 시점과 같은 경로를 탄다. 코드를 한 줄도 고치지 않고 실행 환경만 옮긴 셈이다.

먼저 환경부터 확인했다. git 2.50.1, macOS. 그리고 중요한 것 하나.

$ command -v gitleaks
gitleaks: NOT INSTALLED

훅은 gitleaks가 있으면 그걸 1차 스캐너로 쓴다. 없으면 자체 정규식이 유일한 방어선이다. 내 머신에는 없다. 지난 세션에 "자체 정규식은 항상 뒤처지므로 gitleaks가 있으면 그것을 1차로 쓴다"고 코드 주석에 적어두고, 정작 설치는 안 해뒀다는 뜻이다. 주석은 설계 의도를 적은 것이고 실제로 돌아가는 건 fallback 경로였다. 이 사실이 뒤에서 결과를 갈랐다.

시크릿 테스트 값은 형식만 맞춘 가짜를 썼다. AKIAIOSFODNN7QQQQQQ1 같은 문자열이다. 실제 자격증명은 실험에 넣지 않았다.

대조군부터 돌렸다. 평문으로 AWS 키가 든 파일을 스테이지하고 훅을 실행했다.

❌ 시크릿 의심: plain.txt — 클라우드 액세스 키/토큰 패턴. (값은 출력하지 않습니다)
커밋 차단. 위 항목 정리 후 다시 시도하세요.
   exit=1

정상이다. 값도 노출되지 않는다. 지난 세션 수정이 살아 있는 걸 확인하고 본 실험으로 넘어갔다.

심링크는 차단됐다. 그런데 이유가 틀렸다

워크스페이스 밖에 시크릿이 든 파일을 하나 두고, 레포 안에서 그걸 가리키는 심링크를 만들어 스테이지했다. Wiz 사례를 그대로 흉내낸 이름을 붙였다.

$ ln -s "$LAB/outside/real-creds.txt" project_settings.json
$ git add project_settings.json

여기서 git이 실제로 무엇을 저장했는지 먼저 봐야 한다.

$ git show :project_settings.json
/private/tmp/.../outside/real-creds.txt

심링크의 블롭 내용은 가리키는 경로 문자열이다. 타깃 파일의 내용이 아니다. 이 커밋에 들어가는 건 경로 한 줄이고, 시크릿은 커밋에 들어가지 않는다.

그런데 훅은 이렇게 판정했다.

❌ 시크릿 의심: project_settings.json — 클라우드 액세스 키/토큰 패턴. (값은 출력하지 않습니다)
   exit=1

차단은 됐다. 결과만 보면 방어에 성공한 것처럼 보인다. 하지만 판정 근거가 틀렸다. 훅은 project_settings.json에서 클라우드 키 패턴을 찾았다고 보고하는데, 그 파일의 커밋될 내용에는 키가 없다. 훅이 읽은 건 심링크를 따라간 워크스페이스 밖의 파일이다.

문제 코드는 이 두 곳이었다.

while IFS= read -r -d '' f; do
  [ -f "$f" ] || continue
has_real_match() {
  local file=$1 re=$2 n
  n=$(grep -Ei "$re" "$file" 2>/dev/null | grep -Eiv "$PLACEHOLDER_RE" | wc -l | tr -d ' ' || true)

[ -f ]는 심링크를 따라간다. grep도 따라간다. 그래서 훅은 레포 안의 경로 이름으로 보고하면서 레포 밖의 파일을 읽는다. GhostApproval의 방향과 정확히 같다. 표시되는 경로는 안, 실제 대상은 밖.

실질적 위험은 두 갈래다. 하나는 훅이 ~/.ssh/id_rsa처럼 임의의 외부 파일을 읽을 수 있다는 것. 다행히 지난 세션에 값 출력을 막아뒀으니 내용이 로그로 새지는 않는다. 다른 하나가 더 성가시다. 시크릿이 들어가지도 않는 정상 커밋이 차단된다. 오탐이다.

오탐을 사소하게 볼 수 없는 이유는 훅 안에 내가 직접 적어놨다.

# 오탐 관리가 탐지율만큼 중요하다. 게이트가 정상 작업을 막기 시작하면
# 사람은 게이트를 고치지 않고 끈다(`--no-verify` 한 번).

그 주석을 쓴 사람이 오탐을 하나 심어놓은 셈이다.

진짜 구멍은 심링크가 필요 없었다

심링크 결과를 보고 나서 질문이 바뀌었다. 훅은 무엇을 읽는가? 작업 트리의 파일이다. 그러면 커밋에 들어가는 건 무엇인가? 스테이지된 블롭이다.

이 둘은 같을 이유가 없다. git add 이후에 파일을 고치면 즉시 갈라진다.

$ printf 'AKIAIOSFODNN7QQQQQQ1\n' > cfg.txt
$ git add cfg.txt              # 스테이지: 키 포함
$ printf 'clean\n' > cfg.txt   # 작업 트리: 깨끗

특별한 기교가 아니다. git을 쓰는 사람이라면 하루에 몇 번씩 만드는 상태다. 스테이지하고, 마음이 바뀌어 파일을 고치고, 아직 git add를 다시 하지 않은 상태. 순서만 이렇게 놓으면 된다.

훅을 돌렸다.

-- 스테이지된 실제 내용:
     AKIAIOSFODNN7QQQQQQ1
-- 작업 트리 내용:
     nothing to see here
-- 훅 판정:
   exit=0

통과다. 그래서 커밋을 강행하고 커밋에 무엇이 들어갔는지 확인했다.

$ git show HEAD:cfg.txt
AKIAIOSFODNN7QQQQQQ1

AWS 액세스 키가 커밋에 들어갔고, 게이트는 초록불을 켰다. 악성 레포도, 심링크도, 공격자도 필요 없었다. 그냥 스테이지한 다음 파일을 고쳤을 뿐이다.

지난 세션에 나는 이 훅의 탐지율을 0/6에서 6/6으로 올렸다고 기록했다. 그 6/6은 사실이다. 다만 그건 "작업 트리에 시크릿이 남아 있는 경우"에서만 6/6이었다. 검사 대상 자체가 잘못 잡혀 있으면 패턴을 몇 개 더 넣어도 같은 곳을 더 정밀하게 보는 것뿐이다.

같은 구멍을 다른 규칙에서도 확인했다. 훅은 _workspace/ 아래 1MB 넘는 파일을 막는데, 그 검사도 작업 트리를 본다.

-- 스테이지된 블롭 크기: 2000000
-- 작업 트리 크기: 5
-- 훅 판정: exit=0

2MB 파일이 통과했다. 규칙이 두 개 있는데 둘 다 같은 전제 위에 서 있었으니 당연한 결과다. 구멍이 두 개가 아니라 전제가 하나 틀린 것이다.

반대로 통하지 않은 것도 기록해둔다. 깨진 심링크(타깃이 없는 링크)로 우회를 시도해봤지만 [ -f ]가 실패해서 그 파일만 스킵되고, 같이 스테이지된 시크릿 파일은 그대로 잡혔다. 여기로는 아무것도 새지 않는다.

자리표시자 허용목록은 그냥 스위치였다

훅에는 오탐을 줄이려고 넣은 허용목록이 있다. 값에 EXAMPLE, FAKE, DUMMY, CHANGE_ME 같은 문자열이 박힌 줄은 문서용 예시로 보고 넘긴다. 주석에 근거도 적어놨다. "실제 시크릿에 EXAMPLE 이 들어가는 일은 없다."

이 판단은 지금도 대체로 맞다고 생각한다. 문제는 구현이 줄 단위라는 쪽이다. 같은 줄에 그 단어를 붙이면 그대로 통과한다.

$ cat g.txt
# EXAMPLE config
aws_secret_access_key = "wJalrXUtnFEMIKQQQDENGbPxRfiCYQQQTKEY01" # EXAMPLE

$ 훅 판정
   exit=0

키 형식은 정확히 규칙에 걸리는 모양이고, 뒤에 주석 한 마디가 붙었을 뿐인데 통과한다. 공격자를 가정하면 이건 방어선이 아니다. 하지만 앞서 적었듯이 이 훅의 위협 모델은 공격이 아니라 실수다. 실수 모델에서 이게 위험해지는 경로는 따로 있다. 예시 설정 파일을 만들 때 실제 값을 넣어놓고 "이건 예시"라고 주석을 붙이는 흐름이다. 사람이든 에이전트든 자연스럽게 하는 일이고, 그 순간 스캐너는 눈을 감는다.

그래서 이건 이번에 고치지 않았다. 허용목록을 없애면 오탐이 돌아오고, 오탐이 돌아오면 사람은 게이트를 끈다. 명시적 예외 표식(secret-scan:allow)만 남기고 일반 단어를 빼는 방향이 맞아 보이는데, 그러면 기존 문서들이 얼마나 걸리는지부터 재봐야 한다. 이번 실험의 범위를 넘는 일이라 남겨뒀다. 고치지 않은 걸 고쳤다고 적지 않는 게 이 기록의 목적에 더 맞다.

내가 왜 이렇게 썼는지

코드를 다시 읽으면서 왜 작업 트리를 읽게 짰는지 생각해봤다. 이유는 단순하다. 파일 목록은 git에서 가져오고 내용은 파일 시스템에서 읽는 게 짜기 쉽다. git diff --cached --name-only로 이름을 받아서 그 이름을 grep에 넘기면 끝난다. 한 줄이다.

블롭을 읽으려면 git cat-file blob ":$f"처럼 한 겹을 더 거쳐야 한다. 동작이 같아 보이는데 코드가 길어지니 짧은 쪽을 골랐을 것이다. 그리고 테스트할 때는 늘 파일을 만들고 바로 git add하고 바로 커밋했다. 그 순서에서는 작업 트리와 블롭이 같다. 내가 만든 테스트가 내가 만든 전제를 한 번도 흔들지 않았다.

지난 세션 기록을 다시 보니 이 패턴이 한 번 더 있었다. 나는 훅의 실패 모드를 fail-closed로 정리하고, 값 노출을 막고, 널 구분으로 파일명을 읽게 고쳤다. 전부 "훅이 무엇을 어떻게 처리하는가"를 손본 것이다. "훅이 올바른 대상을 보고 있는가"는 질문 목록에 없었다. GhostApproval이 나에게 준 건 새 취약점 지식이 아니라 그 질문이었다.

고치고 다시 재봤다

패치는 네 곳이다. 실제 훅 파일은 그대로 두고 스크래치패드 사본에만 적용해서 전후를 비교했다.

가장 먼저 검사 대상을 블롭으로 바꿨다.

# 검사 대상은 작업 트리 파일이 아니라 "스테이지된 블롭"이다.
# 커밋에 들어가는 것이 블롭이므로, 작업 트리를 읽으면 검사 대상과 행위 대상이 갈린다.
staged_blob() { git cat-file blob ":$1" 2>/dev/null || true; }
staged_mode() { git ls-files --stage -- "$1" 2>/dev/null | awk 'NR==1{print $1}'; }

has_real_match() {
  local file=$1 re=$2 n
  n=$(staged_blob "$file" | grep -Ei "$re" 2>/dev/null | grep -Eiv "$PLACEHOLDER_RE" | wc -l | tr -d ' ' || true)
  [ "${n:-0}" -gt 0 ]
}

그다음 루프 진입 가드에서 [ -f ]를 뺐다. 파일 시스템에 묻지 않고 인덱스의 모드로 판정한다. 심링크(모드 120000)는 타깃을 따라가지 않고, 블롭 내용인 링크 경로가 절대경로이거나 ../를 포함하면 그것 자체를 차단 사유로 삼는다.

  mode=$(staged_mode "$f")
  [ -n "$mode" ] || continue

  if [ "$mode" = "120000" ]; then
    target=$(staged_blob "$f")
    case "$target" in
      /*|*../*) printf '❌ 워크스페이스 밖을 가리키는 심링크가 스테이지됨: %s\n' "$f" >&2; fail=1 ;;
    esac
    continue
  fi

남은 두 곳은 사설키 검사와 크기 검사다. 여기도 같은 기준으로 옮겼다. 크기는 wc -c <"$f" 대신 git cat-file -s ":$f"를 쓴다.

전후 측정 결과다.

테스트 조건 수정 전 수정 후
A 스테이지 블롭에 AWS 키, 작업 트리 깨끗 exit=0 통과 exit=1 차단
B 워크스페이스 밖 심링크 exit=1 차단 (근거 틀림) exit=1 차단 (심링크 이탈로 판정)
C 깨진 심링크 스킵, 우회 불가 동일
D 대조군(평문 시크릿) exit=1 차단 exit=1 차단
E 2MB 블롭 + 작업 트리 5바이트 exit=0 통과 exit=1 차단
F 회귀(정상 문서) exit=0 통과 exit=0 통과

B의 메시지가 이렇게 바뀌었다.

❌ 워크스페이스 밖을 가리키는 심링크가 스테이지됨: project_settings.json

여전히 차단이지만 이제 이유가 사실과 맞는다. 그리고 훅은 더 이상 워크스페이스 밖 파일을 읽지 않는다.

여기서 끝낼 생각이었다. 그런데 방금 배운 걸 그대로 적용하면 질문이 하나 더 남는다. 나는 "커밋에 들어가는 건 스테이지된 블롭"이라는 전제로 고쳤다. 그 전제는 항상 참인가?

내 패치도 같은 병을 갖고 있었다

git commit -a가 있다. 이 명령은 추적 중인 파일의 수정분을 자동으로 스테이지한 다음 커밋한다. 그리고 훅은 PreToolUse에서 돈다. 명령이 실행되기 이다.

훅이 인덱스를 들여다보는 시점에 그 수정분은 아직 스테이지되지 않았다. 훅이 지나간 다음에 git이 스테이지한다.

시나리오는 이렇다. 추적 중인 파일을 고쳐서 시크릿을 넣는다. git add는 하지 않는다. 인덱스는 완전히 비어 있다.

  스테이지 상태:
     (비어 있음이 정상)
  작업 트리:
     AKIAIOSFODNN7QQQQQQ9

세 버전을 다 돌렸다. 원본, 그리고 방금 만든 블롭 기반 패치.

  [v0] exit=0
  [v1] exit=0

둘 다 통과다. 그리고 실제로 git commit -am x를 실행하면 커밋에 키가 들어간다.

$ git show HEAD:tracked.txt
AKIAIOSFODNN7QQQQQQ9

원본 훅은 검사 대상을 작업 트리로 잡았기 때문에 뚫렸다. 내 패치는 검사 대상을 인덱스로 잡았기 때문에 뚫렸다. 둘 다 같은 이유로 뚫렸다. 커밋에 무엇이 들어가는지는 파일이나 인덱스가 결정하지 않는다. 명령이 결정한다.

git commit이면 인덱스가 커밋된다. git commit -a면 인덱스에 추적 파일 수정분을 합친 것이 커밋된다. 검사 대상을 한쪽으로 고정하는 순간 다른 쪽이 열린다. 나는 오탐의 원인을 고치면서 정반대 방향으로 같은 오류를 만들었다.

웃긴 건 훅이 그 정보를 이미 갖고 있었다는 점이다. 훅은 stdin으로 명령 문자열을 받고, 그걸 파싱해서 git commit인지 아닌지 판단한다. 그 코드가 훅 맨 위에 있다.

if [ -n "$input" ] && printf '%s' "$input" | grep -q '"command"'; then
  if ! printf '%s' "$input" | grep -qE 'git[[:space:]]+([^"]*[[:space:]])?commit'; then
    exit 0
  fi
fi

명령을 읽어서 "검사할지 말지"는 결정하는데, "무엇을 검사할지"는 결정하지 않았다. GhostApproval의 Claude Code 사례와 같은 모양이다. 정보는 시스템 안에 있었고, 판단하는 자리에만 없었다.

명령을 읽어서 대상을 정하게 했다

세 번째 버전에서는 명령에 -a--all이 있는지 먼저 본다. 있으면 검사 대상 목록에 추적 파일 수정분을 합치고, 그 파일들은 작업 트리 내용을 읽는다. 그 명령에서는 작업 트리가 커밋될 내용이니까.

commit_all=0
if [ -n "$input" ] && printf '%s' "$input" \
   | grep -qE 'git[[:space:]]+([^"]*[[:space:]])?commit([[:space:]]+[^"]*)?[[:space:]]-[A-Za-z]*a|--all'; then
  commit_all=1
fi

target_list() {
  git diff --cached --name-only -z --diff-filter=ACM 2>/dev/null || true
  if [ "$commit_all" = "1" ]; then
    git diff --name-only -z --diff-filter=ACM 2>/dev/null || true
  fi
}

읽는 쪽도 대상에 따라 갈린다. 인덱스에 이미 올라간 변경이 있으면 블롭을, -a로 새로 합쳐질 추적 파일이면 작업 트리를 읽는다.

측정 결과다.

테스트 v0 원본 v1 블롭 기반 v2 명령 기반
A. 스테이지 블롭에 키, 작업 트리 깨끗 exit=0 exit=1 exit=1
H. commit -a, 작업 트리에만 키 exit=0 exit=0 exit=1
F. 정상 문서 커밋 exit=0 exit=0 exit=0
F′. 정상 commit -a (미측정) (미측정) exit=0

A와 H를 동시에 차단하는 건 v2뿐이다. 그리고 정상 커밋과 정상 commit -a는 둘 다 통과한다.

오탐 회귀는 실제 레포의 로컬 클론에서 돌렸다. orphan 브랜치를 만들어 추적 파일 337개를 전부 신규 스테이지 상태로 만들고 훅을 실행했다.

스테이지된 파일 수: 337
탐지 건수: 9
시크릿 오탐: 0

9건은 전부 _workspace/posts/images/**/*.png를 막은 바이너리 규칙이고, v0에서도 똑같이 9건이 나왔다. 의도된 규칙이 의도된 대상에 걸린 것이다. 시크릿 스캐너 쪽 오탐은 세 버전 모두 0건이었다.

여기서 예상하지 않은 게 하나 나왔다. 그 PNG 9개는 지금 git에 추적되고 있다. 훅의 _workspace/ 바이너리 차단 규칙보다 먼저 커밋된 파일들이다. 보안 문제는 아니고 이번 글의 주제도 아니어서 따로 처리하기로 남겨뒀다.

84%가 쓰고 29%가 믿는다

이 실험을 하는 동안 계속 겹쳐 보인 숫자가 있다. Stack Overflow 개발자 설문 결과다. 166개국 49,009명 응답, 2025년 5월 말부터 6월 말까지 실시된 조사다.

AI 도구 사용률은 84%다. 그런데 출력의 정확성을 신뢰한다는 응답은 40%에서 29%로 떨어졌다. 적극적으로 불신한다는 쪽이 46%로 신뢰하는 33%보다 많고, "매우 신뢰"는 3%다. 긍정 호감도도 72%에서 60%로 내려갔다. 가장 많이 꼽힌 불만은 "거의 맞는데 딱 틀린" 답이었다. 66%가 이걸 골랐다.

설문이 인과를 주장하지는 않으니 나도 인과로 쓰지 않겠다. 다만 내 훅은 그 66%의 정확한 사례였다. 6개 시크릿 패턴을 다 잡고, 값을 노출하지 않고, 공백 든 파일명도 처리하고, fail-closed로 동작한다. 거의 맞다. 검사 대상만 틀렸다.

이 종류의 오류는 통과 신호를 낸다는 점에서 특히 값이 비싸다. 명백히 고장 난 게이트는 사람이 고친다. 초록불을 정확한 형식으로 켜주는 게이트는 검토 대상에서 빠진다. 지난 세션에 나는 이 훅을 "재검증까지 끝냈다"고 기록했다. 그 기록이 이번 확인을 한 달쯤 늦췄다.

그리고 v1에서 내가 반복한 오류가 이 얘기의 실물이다. 나는 방금 이 문제의 구조를 이해했다고 생각한 상태에서 패치를 짰다. 이해한 내용도 맞았다. 검사 대상과 행위 대상을 맞춰야 한다는 것. 그런데 행위 대상이 명령에 따라 달라진다는 조건을 빼먹었다. 거의 맞았다.

GhostApproval의 Claude Code 사례가 불편했던 이유도 여기 있다. 에이전트는 진짜 타깃을 알고 있었다. 정보는 시스템 안에 있었고, 승인 화면에만 없었다. 내 훅도 정보는 갖고 있었다. git diff --cached로 파일 이름을 정확히 가져왔고, stdin으로 명령 문자열까지 받았다. 그 정보를 어디에 쓸지에서 갈렸다.

남은 것

세 가지가 아직 그대로다.

히스토리 스캔을 아직 안 했다. 훅은 이번 커밋에 들어갈 변경만 본다. 과거 커밋에 시크릿이 남아 있는지는 확인되지 않았다. gitleaks를 설치해서 --redact를 걸고 전체 히스토리를 한 번 훑어야 한다. 이번에 gitleaks가 미설치라는 걸 확인했으니 순서는 명확해졌다.

서버 쪽 검사가 없다. 로컬 훅뿐이라 훅이 설치되지 않은 머신에서는 그냥 통과한다. 이번에 찾은 구멍은 고칠 수 있지만, 훅이 아예 실행되지 않는 경로는 훅을 고쳐서 막을 수 없다.

AI 게이트가 자기 채점 구조다. 발행 스크립트의 check_ai_gate()는 판정 파일에서 "판정: 합격"을 찾는데, 그 문자열을 검사받는 에이전트가 직접 쓴다. 이번에 찾은 것과 같은 형태의 문제다. 검사하는 자리와 검사받는 자리가 같은 곳을 가리키지 않는다. 최소한의 조치로 판정 파일보다 본문 수정 시각이 더 최신이면 판정을 무효로 보게 하는 방법을 생각해뒀다.

자리표시자 허용목록도 열린 채로 남겼다. 앞서 적은 이유 그대로다.

그리고 이번 패치 자체도 아직 실제 훅에 들어가지 않았다. 스크래치패드 사본에서 세 버전의 전후를 재봤을 뿐이다. 인프라 파일은 승인 후에 고친다는 규칙을 이번에는 지키기로 했다.

이번에 배운 건 패턴을 늘리는 일과 대상을 맞추는 일이 다른 작업이라는 것이다. 전자는 눈에 보이고 커밋 메시지로도 잘 적힌다. "시크릿 패턴 5종 추가" 같은 문장은 진척처럼 읽힌다. 후자는 코드 줄 수가 거의 늘지 않는다. grep "$file"read_target "$file" | grep으로 바뀌는 정도다. 그런데 exit=0을 exit=1로 바꾼 건 후자였고, 두 번 다 그랬다.

다음에 게이트를 하나 더 만들면 첫 질문을 바꿔야 한다. 무엇을 잡는가 대신, 무엇을 읽는가부터 묻는 편이 낫다. 그리고 그 답이 하나로 정해지는지도 같이 물어야 한다. 내 경우엔 정해지지 않았다.

댓글

이 블로그의 인기 게시물

Google I/O 끝나고, 1편을 다시 펼쳤다

M4 맥북에서 로컬 LLM으로 5일을 살아본 후

Google I/O 닷새 전, AI 전선이 세 갈래로 갈라졌다