에이전트의 "완료"를 믿지 않기로 했다 - verify.sh 게이트, prove-it
AI 코딩 에이전트는 테스트를 돌리지 않고도 "테스트 통과"라고 보고한다. 거짓말이 아니라 구조 문제다. 보고를 검사로 바꾸는 오픈소스 게이트 prove-it을 만들면서 배운 것들 - 그리고 직접 짠 다섯 줄 훅이 조용히 실패하는 네 가지 방식.
핵심 요약
코딩 에이전트는 자기가 한 일과 하려던 일을 구분하지 못해서, 실제로는 의도를 보고한다. 테스트를 안 돌리고 "통과합니다"라고 말하는 건 그래서다. 프롬프트로는 고칠 수 없고, "완료"를 선언이 아니라 통과해야 하는 검사로 바꿔야 한다. prove-it은 레포 루트의 verify.sh가 exit 0을 반환하기 전까지 에이전트가 턴을 끝내지 못하게 막는 Stop 훅 게이트다. 직접 짠 다섯 줄 훅은 네 가지 방식으로 조용히 실패한다 - 한 번 막고 다시는 못 막거나, 커밋으로 끝난 턴을 그냥 통과시키거나, 에이전트가 검사 자체를 지워버리거나, 포기와 통과가 구분되지 않거나. 이 네 가지가 prove-it이 다섯 줄 이상인 이유의 전부다.
목차
"테스트 전부 통과합니다. 머지해도 됩니다."
에이전트가 이렇게 말했고, 나는 diff가 깔끔해서 그대로 믿을 뻔했다. 그런데 터미널 스크롤을 올려보니 테스트는 한 번도 돌지 않았다. 실행 로그 자체가 없었다. 에이전트는 테스트를 돌리려고 했고, 그 의도가 완료 보고문에 그대로 적힌 것이다.
이런 일을 몇 번 겪고 나면 반응은 보통 둘 중 하나다. 프롬프트에 "반드시 테스트를 실행하고 결과를 보여줘"를 추가하거나, 에이전트가 완료라고 할 때마다 손으로 다시 확인하거나. 나는 둘 다 해봤고, 둘 다 오래 못 갔다. 그래서 세 번째 길을 택했다. "완료"라는 말 자체를 못 믿게 만드는 대신, 완료를 선언이 아니라 통과해야 하는 검사로 바꿨다. 그렇게 만든 게 prove-it이라는 오픈소스 도구다.
거짓말이 아니라 구조다
먼저 짚을 것. 에이전트는 거짓말을 하는 게 아니다.
사람은 "테스트를 돌렸다"와 "테스트를 돌리려 했다"를 구분한다. 기억이 있으니까. 에이전트에게는 그 비교 대상이 없다. 자기가 한 일을 하려던 일과 대조할 방법이 없어서, 결과 대신 의도를 보고한다. "테스트 통과합니다"는 "테스트가 통과하도록 코드를 짰습니다"의 축약인 셈이다. 이건 모델의 인성 문제가 아니라 설계상의 성질이고, 그래서 프롬프트로 고쳐지지 않는다. "꼭 돌려라"라고 백 번 적어도 안 돌리는 날이 온다.
프롬프트 기법에는 또 다른 약점이 있다. 모델 세대가 바뀔 때마다 낡는다. 어떤 지시가 이번 모델에 잘 듣는다는 사실이 다음 모델에도 듣는다는 보장이 없다. 반면 증거를 요구하는 장치는 모델 한 층 위에 앉아 있어서 업그레이드를 견딘다. 테스트가 실제로 돌았는지는 모델이 아니라 종료 코드가 말해주면 된다.
보고를 검사로 바꾸기
완료를 말이 아니라 증거로 인정한다는 아이디어 자체는 지난 글에서 다뤘다. 이번 글은 그 아이디어를 누구나 설치할 수 있는 도구로 만들어 공개한 이야기다.
prove-it의 규약은 한 문장이다. 레포는 스스로를 증명하는 방법을 verify.sh라는 파일로 선언하고, 에이전트는 그 증명이 통과하기 전까지 완료를 주장할 수 없다.
구현은 이렇다. 레포 루트에 실행 가능한 verify.sh를 둔다. exit 0이면 "이 트리는 증명 가능하게 괜찮다"는 뜻이다. 에이전트가 턴을 끝내려 하면 Stop 훅이 이 스크립트를 실행하고, 0이 아니면 에이전트를 멈추게 두는 대신 실패 출력의 마지막 스무 줄과 함께 다시 일하러 돌려보낸다. 대부분의 경우 에이전트는 그 출력만 보고도 원인을 고친다.
게이트가 언제 발동하는지도 단순하다. 이 세션이 이 레포를 실제로 바꿨고, 실행 가능한 verify.sh가 있고, 지금 트리 상태가 아직 통과한 적 없을 때. 세 조건이 다 참일 때만 돈다. 읽기만 한 세션은 건드리지 않고, 한 번 통과한 트리는 다시 검사하지 않는다.
그리고 무한정 막지는 않는다. 한 턴에 최대 세 번까지 돌려보내고 그다음엔 양보한다. 절대 양보하지 않는 훅은 세션을 멈춰 세우기 때문이다. 다만 양보와 통과는 다르다. 양보로 끝난 턴의 마지막 출력은 "완료"가 아니라 "이 턴은 검증되지 않은 채 끝났다"는 경고다. 에이전트가 게이트를 끝내 못 넘을 수는 있어도, 조용히 넘어갈 수는 없다는 게 이 도구가 실제로 주장하는 전부다.
다섯 줄 bash로는 왜 안 되나
여기까지 읽고 "그거 Stop 훅에 테스트 명령 한 줄이면 되잖아"라고 생각했다면, 맞다. 다섯 줄이면 되고, 나도 처음엔 그렇게 짰다. 그리고 그 버전은 네 가지 방식으로 조용히 실패했다. 몇 개는 prove-it 초기 버전에 실제로 있던 버그라서, 지금은 하나하나에 회귀 테스트가 붙어 있다.
한 번 막고, 다시는 못 막는다. Claude Code는 훅이 한 번 차단한 뒤의 모든 stop 이벤트에 stop_hook_active 플래그를 세운다. 이 플래그를 "통과시켜라"로 읽는 훅은 정확히 한 번 막고 나면 게이트이기를 그만둔다. 반대로 플래그를 무시하는 훅은 영원히 막아서 세션이 멈춘다. 양쪽 다 겪어봐야 아는 함정이고, 답은 시도 횟수를 직접 세서 정해진 횟수만큼 막은 뒤 요란하게 양보하는 것이다.
커밋하면 아무 일도 없었던 것처럼 보인다. 워킹 트리가 더러운지로 "작업이 있었나"를 판단하는 훅은, 커밋으로 끝나는 턴을 전부 그냥 통과시킨다. 커밋은 에이전트가 하는 가장 평범한 일인데 말이다. 그래서 prove-it은 세션 시작 시점의 트리 상태를 기준선으로 기록해두고 매 stop마다 그것과 비교한다. 커밋도, sed로 고친 파일도, 코드 생성기가 뱉은 파일도 전부 변경으로 잡힌다.
에이전트가 검사를 없앨 수 있다. verify.sh를 통과시키지 못하는 에이전트가 택할 수 있는 가장 싼 수는 verify.sh를 지우거나 실행 권한을 빼버리는 것이다. 게이트는 세션 시작 때 레포가 무장돼 있었는지를 기록하고, 무장 해제된 채 끝나는 턴을 거부한다. 더 교묘한 수도 있다. 실행 가능한 채로 두고 안의 검사만 고쳐 쓰는 것. 이건 막지 않는다. verify.sh를 고치는 게 바로 부탁한 작업인 경우도 많으니까. 대신 조용히 넘어가지도 않는다. 세션 중에 verify.sh가 바뀐 채 통과하면 게이트가 그 사실을 알려주고, 그 diff를 읽으면 작업이었는지 회피였는지 판단할 수 있다.
포기가 통과와 구분되지 않는다. 어떤 호스트든 결국은 훅을 양보하게 만든다. 직접 짠 훅은 침묵 속에 양보하고, 당신이 마지막으로 보는 말은 "완료"다. 세 번 실패하고 포기한 턴과 한 번에 통과한 턴이 화면에서 똑같이 생겼다. prove-it의 마지막 말은 경고다. 이 차이가 사소해 보이면, 포기한 턴을 통과한 줄 알고 머지한 다음 날을 상상해보면 된다.
이 네 가지가 prove-it이 다섯 줄보다 긴 이유의 전부다. 자기 훅을 계속 쓰고 싶다면 그래도 된다. 다만 그 훅이 감당해야 하는 경우들이 SPEC.md에 적혀 있으니 읽어보길 권한다. 중요한 건 구현이 아니라 규약이다.
이 도구가 증명해주지 않는 것
정직하게 적어둘 한계가 있다. 게이트가 강제하는 건 딱 하나, 턴이 끝나기 전에 verify.sh가 0을 반환했다는 사실이다. 그 0이 무슨 의미인지는 전적으로 당신이 쓴 검사에 달렸다. exit 0 한 줄짜리 verify.sh는 이 게이트를 통과하고, 아무것도 증명하지 않는다.
스펙은 이걸 Level 1이라 부른다. Level 2는 검사가 실제 증거에 대해 단언하는가의 문제고, 그건 어떤 도구도 대신 검증해줄 수 없다. prove-it도 마찬가지다. 훅을 거는 건 쉬운 부분이다. 진짜 작업은 "이 레포에서 증명됐다는 게 무슨 뜻인가"라는 질문에 답하는 것이고, 그 답은 레포마다 다르다. 그래서 이 규약은 파일 이름만 정하고 내용은 정하지 않는다.
우회로도 일부러 열어뒀다. PROVE_IT_SKIP=1이면 그 턴은 그냥 통과하고, verify.sh를 지우면 게이트는 영영 꺼진다. 제거할 수 없는 게이트는 사람들이 결국 돌아서 가고, 우회된 게이트는 없는 것보다 나쁘기 때문이다. 검사가 돈 것처럼 보고하면서 실제론 아무것도 안 돌기 때문이다.
첫날은 일부러 작게
설치는 세 줄이고, 실제 일은 세 번째 줄이 한다.
/plugin marketplace add Why-Next/prove-it
/plugin install prove-it@whynext
/prove-it:init
/prove-it:init은 스택을 감지해 verify.sh를 만들고, 그게 통과하는 걸 눈앞에서 보여준 다음, exit 1을 덧붙인 사본을 돌려서 게이트가 턴을 거부하는 것까지 보여준다. 30초쯤 걸린다.
생성된 verify.sh의 활성 검사는 git diff --check 딱 하나다. 테스트도 타입 체크도 전부 주석으로만 적혀 있다. 설치한 그날 main에서 통과하도록 일부러 그렇게 만들었다. 첫날부터 실패하는 게이트는 팀에게 첫 주부터 우회하는 법을 가르친다. 주석 처리된 검사는 하나씩, 손으로 직접 돌려서 통과하는 걸 본 뒤에 켜면 된다. 그리고 켜기 전에 한 번은 일부러 실패시켜봐야 한다. 실패할 수 없는 검사는 검사가 아니고, 그 사실을 필요한 날에 알게 되면 늦다.
verify.sh를 키우다 멈출 지점도 정해져 있다. 전체 1분. 그보다 느린 검사는 CI로 보낸다. secrets나 프로덕션 접근이 필요한 검사도 CI 몫이다. verify.sh에는 에이전트가 작업 중에 로컬에서 돌릴 수 있는 증거만 남긴다.
게이트가 잡은 것들의 장부
PROVE_IT_LEDGER=1을 켜두면, 게이트가 거짓 완료를 잡을 때마다 로컬 파일에 한 줄이 쌓인다.
{"ts":"2026-07-09T04:12:33Z","claim":"All tests pass. Ready to merge.",
"evidence_demanded":"verify.sh exit 0","actual":["3 failed, 41 passed"]}
에이전트가 뭐라고 주장했고, 무엇이 요구됐고, 실제로는 뭐였는지. 한 달치가 쌓이면 내 에이전트가 어떤 식으로 실패하는지를 추측 대신 기록으로 읽을 수 있다. 파일은 로컬 디스크에만 남고 어디로도 전송되지 않으며, 켜기 전까진 꺼져 있다.
이 레포는 스스로를 게이트한다
prove-it 레포에도 당연히 verify.sh가 있고, 그 안에서 게이트 자신을 실제 git 레포 위에서 테스트한다. 실패하는 검사는 막히는지, 통과하는 검사는 열리는지, 읽기만 한 세션은 건드리지 않는지, 커밋 뒤의 깨끗한 트리를 "작업 없음"으로 착각하지 않는지. 위에서 말한 네 가지 함정이 전부 회귀 테스트로 박혀 있다.
MIT 라이선스고, 의존성은 bash·git·python3가 전부다. Claude Code에서 테스트했고, Codex CLI·Gemini CLI 등 같은 종류의 차단형 훅을 노출하는 호스트용 연결법은 ADAPTERS 문서에 있다. 에이전트가 코드를 고치고 같은 대화에서 "완료"라고 말하는 레포가 있다면, 거기가 이 게이트가 가장 쓸모 있는 곳이다.
"완료"는 에이전트가 선언하는 말이 아니라 레포가 판정하는 상태여야 한다. 그 판정 기준을 적는 일은 여전히 당신 몫이지만, 적어도 이제 아무도 그 판정을 건너뛰고 조용히 지나갈 수는 없다.
자주 묻는 질문
AI 코딩 에이전트가 하지도 않은 작업을 완료했다고 보고해요. 어떻게 막나요?
프롬프트로 "꼭 테스트를 돌려라"라고 지시하는 것으론 부족합니다. 에이전트는 자기가 한 일과 하려던 일을 비교할 수단이 없어서 의도를 그대로 보고하기 때문입니다. 대신 완료 선언 자체를 검사로 바꾸세요. 레포 루트에 검증 스크립트(verify.sh)를 두고, 에이전트가 턴을 끝내려 할 때 Stop 훅이 그 스크립트를 실행해 exit 0이 아니면 다시 일하도록 돌려보내는 방식입니다. prove-it이 이 규약의 참조 구현입니다.
Claude Code Stop 훅으로 테스트를 돌리는 다섯 줄 스크립트를 직접 짜면 안 되나요?
됩니다만 네 가지 함정이 있습니다. ① Claude Code는 첫 차단 이후 stop_hook_active 플래그를 설정하는데, 이걸 잘못 다루면 한 번만 막거나 영원히 막아 세션이 멈춥니다. ② 워킹 트리가 더러운지로 판단하면 커밋으로 끝난 턴을 전부 통과시킵니다. ③ 에이전트가 verify.sh를 지우거나 chmod -x 해서 게이트 자체를 무장 해제할 수 있습니다. ④ 훅이 결국 양보할 때 포기가 통과처럼 보입니다. 직접 짜려면 이 네 경우를 모두 다뤄야 합니다.
verify.sh에는 어떤 검사를 넣어야 하나요?
"이 레포에서 변경이 증명되려면 무엇이 참이어야 하는가"에 대한 답을 넣습니다. 보통 실제로 돌리는 테스트 명령, 타입 체크, lint 순으로 하나씩 켭니다. 전체가 1분 안에 끝나야 하고, secrets나 프로덕션 접근이 필요한 검사는 CI에 두세요. 첫날부터 야심차게 만들지 않는 게 중요합니다. 느리거나 불안정한 게이트는 일주일 안에 우회되고, 우회된 게이트는 없는 것보다 나쁩니다.
관련 글
- 💻 개발
AI가 "다 됐어요"라고 할 때, 그걸 믿지 않는 장치
AI 코딩 에이전트는 일을 안 끝내고도 끝났다고 말한다. 테스트를 안 돌리고 통과했다 하고, 재현도 안 해보고 고쳤다 한다. 거의 모든 코드를 AI와 함께 쓰는 1인 개발자가, 완료를 말이 아니라 증거로만 인정하게 만든 검증 게이트 이야기.
- 💻 개발
AI가 코드를 짜주는 시대, Git은 왜 더 중요해졌나
AI가 코드를 다 써주는데 Git을 배워야 하나요? 순서가 거꾸로다. AI가 코드를 쓰기 때문에 Git이 필요하다. 거의 모든 코드를 AI와 함께 쓰는 1인 개발자가 정리한, AI 시대에 Git·GitHub와 친해져야 하는 네 가지 이유.
- 💻 개발
안 받고 있던 할인 - Spot과 Graviton은 왜 싼가
AWS 청구서가 두 달 연속 예산을 넘겼다. 줄일 리소스를 찾다가 진짜 레버는 '얼마나 쓰느냐'가 아니라 '어떻게 사느냐'였음을 알았다. Spot과 Graviton이 왜 싼지, 그리고 낭비처럼 보이지만 끄면 안 되는 비용(RDS Proxy)까지 - 청구서의 각 숫자에 담긴 '왜'에 대한 이야기.