hiway-kit

초록불은 거짓말을 한다: 내가 만든 완료 게이트를 6방향 적대적 감사로 뜯어본 기록

완료 게이트가 초록불을 켰는데, 6개의 fresh-context 적대적 리뷰어를 프로젝트 전체에 풀자 바로 그 게이트들이 실패를 초록불로 보고하고 있었다. 검증 기계 자체를 검증한 기록, 그리고 '작성자는 오염돼 있다'가 왜 게이트를 짠 나에게 가장 아프게 적용되는지.

앞선 세 글 — 하네스/루프 엔지니어링 지형도, 병렬 git 격리 실전 적용, durable 완료 게이트 설계 — 은 모두 “검증을 기계로 만들자"는 방향으로 수렴했다. 이 글은 그 방향이 도달하는 불편한 종착지에 관한 것이다: 그 검증 기계는 누가 검증하는가?

핵심 요약

완료 게이트가 exit 0(초록불)을 켰다는 것과, 코드가 옳다는 것은 다르다.

프로젝트 전체에 6개의 독립된 적대적 리뷰어를 풀자, 가장 심각한 결함은 기능 코드가 아니라 검증 기계 자체에 있었다 — 실패를 초록불로 보고하는 false-green. 게이트가 거짓말을 하고 있었다.

그리고 그 게이트를 만든 건 나였다. “작성자는 오염돼 있다"는 명제는, 검증 도구를 짤 때 가장 아프게 참이 된다.

1. 초록불에서 시작한 감사#

직전 릴리스(v2.9.0)는 모든 기계 검사를 통과했다. 킷 레포 자신의 완료 게이트인 verify-done.sh는 초록불이었고, 유닛 테스트도 전부 녹색이었고, CI도 성공이었다. 보통은 여기서 “완료"라고 말한다.

대신 나는 프로젝트 전체에 6개의 fresh-context 적대적 리뷰어를 병렬로 풀었다 — 각자 다른 차원을 맡아, “이 코드는 틀렸다, 아직 못 찾았을 뿐이다"는 자세로:

  1. Python 훅 (보안·레이스·우회)
  2. 셸 스크립트 (게이트 false-green·이식성)
  3. 에이전트 거버넌스 (당시 에이전트 정의 전체의 일관성)
  4. 스킬·룰 코히런스 (죽은 참조·SSOT)
  5. 문서·CI 정합성 (버전 drift·CI 발산)
  6. 직전 병합 코드의 2차 심층 재리뷰

이들이 돌아와서 보고한 것은, 초록불이 가리고 있던 것들이었다.

2. 가장 위험한 계급: false-green#

버그에는 계급이 있다. 크래시는 시끄럽다 — 최소한 자기가 실패했다고 알린다. 가장 위험한 건 조용한 것, 실패를 성공이라고 보고하는 것이다. 완료 게이트에서 이건 최악이다. 게이트의 존재 이유가 “실패를 잡는 것"인데, 그 게이트가 실패를 초록불로 삼키면 아무도 안 잡는다.

리뷰어들이 찾은 false-green은 다섯 개였고, 그중 다수가 내가 바로 직전에 만든 게이트 안에 있었다.

checklist 손상 파일이 통과된다. durable 완료 게이트의 핵심은 checklist.json을 읽어 미완 항목이 있으면 FAIL하는 것이었다. 그런데 파싱 실패와 파일 부재를 똑같이 “skip"으로 처리하고 있었다. echo '[]' > checklist.json 한 줄이면 — 파일을 비우거나 망가뜨리면 — 게이트가 조용히 통과했다. 게이트를 우회하는 방법이 게이트 파일을 부수는 것이라니, 모델이 자기 완료를 못 찍게 만들겠다던 설계가 파일 하나 truncate로 뚫렸다. 고친 뒤로는 파일이 진짜 없을 때만 skip이고, 존재하는데 비었거나 리스트가 아니거나 파싱에 실패하면 FAIL이다.

test-ratchet이 main에서 눈이 멀어 있다. 테스트 삭제를 막는 래칫은 git merge-base main HEAD를 기준으로 diff를 봤다. 그런데 HEAD가 main일 때 merge-base는 HEAD 자신이라, diff가 비어버린다 — 커밋된 테스트 삭제가 보이지 않는다. 게다가 main ref가 없으면 조용히 초록불이었다. 래칫이 정확히 필요한 순간(main에 직접 커밋)에 작동하지 않았다. 이때는 origin/main → HEAD~1 순으로 기준을 폴백하도록 고쳤는데, 이 래칫에는 구멍이 더 있었다는 게 나중에 드러난다(말미 참고).

helper를 지우면 게이트가 사라진다. verify-done.sh는 checklist 검증 도구가 없으면 검사 블록 전체를 건너뛰고 초록불을 켰다. 미완 checklist가 쌓여 있어도, 그것을 검사할 도구가 없다는 이유로 통과. 안전장치가 자기 부품이 빠지면 fail-open 했다.

이 셋의 공통 교훈: 부재·오류·손상을 “통과"로 해석하지 마라. 게이트는 확실히 초록일 때만 초록이어야 하고, 모르면 빨강이어야 한다(fail-closed).

3. 막겠다고 만든 문이 열려 있었다#

보안 쪽에서 리뷰어가 찾은 건 더 직접적이었다. 시크릿 파일(.env, SSH 키 등)의 편집을 차단하는 훅(protect-sensitive)이 있었다. 그 훅의 매처는 편집·읽기 도구 이름을 나열한 목록이었는데, 거기에 MultiEdit와 NotebookEdit가 빠져 있었다. Claude Code의 훅 매처는 이런 이름 목록을 정확한 이름으로 매칭한다 — MultiEdit는 Edit에 안 걸린다.

즉 MultiEdit(".env") 한 번이면 시크릿 차단이 통째로 우회됐다. 문을 잠갔는데 옆에 잠그지 않은 문이 하나 더 있었던 것이다. 매처에 MultiEdit|NotebookEdit를 추가하고, 노트북이 쓰는 다른 경로 필드(notebook_path)까지 검사하도록 고쳤다. 그리고 이 훅이 “커밋의 시크릿을 막는다"던 문서 주장도 정정했다 — 실제로는 경로 기반이고 Bash·commit은 안 잡는다(그건 gitleaks의 몫이다). 도구가 실제로 하는 것과 문서가 약속하는 것의 간극도 결함이다.

4. 매 세션 주입되던 죽은 지도#

세 번째 계열은 조용하지만 누적되는 종류였다. 당시 이 프로젝트는 매 세션 시작 시 규칙(rules)을 컨텍스트에 상시 주입했다. 그 always-injected 규칙 안에 존재하지 않는 것들을 가리키는 참조가 있었다:

  • 없는 에이전트 넷(schedule-task, notify-team 등)을 “이렇게 설정하라"고 지시하는 줄.
  • 존재하지 않는 스크립트(./scripts/db-tunnel.sh)를 “쓰기 전에 실행하라"는 줄.
  • “plugin.json의 에이전트 목록을 확인하라"는데 — 그런 목록은 없다(에이전트는 디렉토리에서 자동 발견된다).
  • 당시의 위임 시그널 포맷이 두 규칙에서 서로 다른 버전으로 존재(한쪽은 4-type, 한쪽은 3-type)하며, 한쪽은 자기가 “단일 진실(SSOT)“이라 선언하고 있었다.

이건 코드를 깨뜨리진 않지만, 매 세션 모델에게 틀린 지도를 쥐여준다. 모델이 없는 에이전트로 위임하려 시도하고, 없는 스크립트를 찾는다. 죽은 참조를 모두 제거하고, 위임 포맷을 canonical 하나로 정렬했다. (그 위임 시그널 자체는 이후 v2.16.0에서 통째로 폐기됐다 — 그것을 파싱하는 결정론적 코드가 어디에도 없었기 때문이다.)

결함을 고치는 것보다 중요한 것: 그 계급의 결함을 게이트가 잡게 만들기. 죽은 스크립트 참조가 반복 발견됐으므로, verify-done.sh가 rules·agents 문서의 scripts/*.sh 참조가 실재하는지 기계로 검증하도록 했다. 이 검사는 이후 skills 문서와, 배포물 안에서 서로를 가리키는 컴포넌트 경로까지 넓어졌다. 다음번 db-tunnel.sh는 사람 눈이 아니라 게이트가 잡는다.

그리고 같은 정신으로 CI의 구멍도 하나 메웠다. CI의 테스트 스텝이 pytest ... || echo "skip"으로 되어 있었다 — 테스트가 실패해도 초록불이었다. 직전 릴리스에서 추가한 25개의 새 테스트가 전부 빨강이어도 CI는 통과했을 것이다. 검증 파이프라인 자신이 false-green이었던 셈이다.

5. 작성자는 오염돼 있다 — 특히 게이트를 짤 때#

이 시리즈에서 반복된 명제가 있다. “작성자는 오염돼 있다. 자기 검증은 자기가 닫았다고 믿은 구멍을 놓친다.” 이번 감사는 그 명제의 가장 날카로운 판본을 보여줬다.

내가 놓친 결함들은 검증 기계 자체에 있었다. 나는 “모델이 자기 완료를 못 찍게” 하는 게이트를 설계했다고 믿었는데, 그 게이트는 손상된 파일에 뚫렸고, main에서 눈이 멀었고, 부품이 빠지면 열렸다. 내 초록불은 진짜였다 — 모든 기계 검사를 통과했다. 그런데도 게이트는 거짓말을 하고 있었다.

자기 검증으로는 절대 못 봤을 것들이다. 왜냐하면 나는 그 게이트가 옳다고 믿으면서 짰기 때문이다. 그 믿음이 바로 오염이다. 오염을 씻는 유일한 방법은, 그것을 짜지 않은 눈 — fresh context, 분리된 세션, “이건 틀렸다"는 자세로 시작하는 리뷰어 — 에게 넘기는 것이다.

6. 남긴 것, 그리고 정직한 한계#

모든 수정은 E2E로 검증했다: MultiEdit(".env")가 실제로 차단되는지(exit 2), 손상된 checklist가 실제로 게이트를 FAIL시키는지(exit 1), 169개 테스트와 완료 게이트가 여전히 초록인지. 그리고 CI가 — 이제 진짜로 테스트를 돌리는 CI가 — 성공하는지.

처음엔 두 가지를 “정직한 한계"로 남겼다 — checklist의 passes가 게이트 시점에 재검증되지 않는다는 점(F1)과, verify 서브프로세스가 타임아웃 시 손자 프로세스를 남길 수 있다는 점(F6). 그런데 “애매하게 남기지 말고 깔끔하게 정리하라"는 지적을 받고, 둘 다 마저 손봤다.

  • F6은 그냥 버그였다. verify를 새 프로세스 그룹으로 띄우고 타임아웃 시 그룹 전체를 종료하도록(killpg) 고쳤다. 겸사겸사 락을 verify 실행 내내 쥐고 있던 것도 풀어, 검증은 락 밖에서 돌고 결과 기록만 락 안에서 하도록 분리했다. 다만 같은 날 이어진 재감사에서 이것도 과장이 지적됐다 — 자손이 스스로 setsid나 데몬화로 그룹을 벗어나면 killpg가 닿지 않는다. 그래서 “손자를 안 남긴다"가 아니라 best-effort 정리라고 문서를 고쳤다.
  • F1은 트레이드오프였다 — 게이트 시점에 모든 verify를 자동 재실행하면 재귀와 side-effect(재배포 같은)라는 더 큰 부채가 생긴다. 그래서 “자동으로 늘 재검증"이 아니라 verify라는 opt-in 명령을 더했다. status는 빠른 원장 조회로 남고, 지금 다시 증명하고 싶으면 verify가 전 항목을 재실행해 회귀한 passes:true를 false로 되돌린다. 자동 게이트는 여전히 원장만 읽는다 — 한계가 사라진 게 아니라, “못 한다"가 “필요하면 이 명령으로 한다"로 바뀐 것이다.

교훈이 하나 더 붙는다. “정직한 한계"라는 라벨은 종종 “아직 안 고친 것"의 완곡어법이다. 진짜 트레이드오프(F1)와 그냥 미룬 버그(F6)를 구분하고, 후자는 마저 닫는 것 — 그것이 애매함을 남기지 않는 유일한 방법이다.

교훈을 한 줄로 남긴다면: 초록불은 “통과했다"는 뜻이지 “옳다"는 뜻이 아니다. 그리고 그 초록불을 켜는 기계를 당신이 직접 만들었다면, 그 기계야말로 가장 먼저 의심해야 할 대상이다.

이후 바뀐 것#

2026-10-02 갱신. 위 본문은 2026-07-03 시점의 기록이다. 이후 킷에서 바뀐 것:

  • test-ratchet에 구멍이 더 있었다. v2.16.0에서 기준 대비 순증감만 보던 방식이 “먼저 추가하고 나중에 지우면” 통과한다는 게 드러나, 커밋 단위 검사를 더했고 허용 마커(TEST-RATCHET-ALLOW)도 diff 아무 곳이 아니라 커밋 메시지에서 찾도록 바꿨다. 게이트가 있다는 사실이 방어를 보증하지 않는다는 이 글의 교훈이 같은 게이트에 한 번 더 적용됐다.
  • 위임 시그널은 폐기됐다. 4장에서 정렬한 위임 시그널 블록은 v2.16.0에서 계약째 걷어냈다. 이를 파싱하는 결정론적 코드가 없었다.
  • 규칙은 더 이상 전부 상시 주입되지 않는다. v2.18.0부터 각 규칙이 core(상시)·conditional(신호가 있을 때)·reference(색인 한 줄만) 중 하나를 선언한다. 4장에 나온 agent-system은 참조 등급, mcp-usage는 조건부 등급이다.
  • checklist는 계획 파일 옆으로 옮겼다. v4.0.0에서 Work 시스템을 걷어내면서 checklist.json은 docs/plans/<날짜>-<slug>/ 아래 plan.md 옆에 놓이고, verify-done.sh의 checklist 게이트는 활성 계획의 것만 검사한다.
  • 항목 통과 명령 이름이 바뀌었다. v5.0.1에서 checklist pass가 checklist complete로 바뀌었다(옛 이름은 별칭으로 받는다). status·verify 명령과 passes 필드는 그대로다.
  • checklist 도구 위치가 바뀌었다. v5.2.0에서 checklist.py가 hooks/에서 plugins/common/tools/로 옮겨졌다. helper 부재 시 fail-closed 동작은 그대로다.
  • 에이전트 수가 크게 줄었다. v5.0.0에서 킷의 에이전트를 절반 이하로 정리했다. 3번 리뷰어가 맡았던 “에이전트 정의 전체"는 지금보다 훨씬 큰 집합이었다.
  • protect-sensitive의 범위를 더 정확히 적는다. 현재 매처는 Edit|MultiEdit|Write|NotebookEdit|Read이고, 경로 기반 차단에 더해 env 템플릿(.env.example 등) 쓰기에만 best-effort 내용 스캔을 한다. Codex용 배포본에는 이 훅을 싣지 않는다 — 실측에서 차단이 명령을 멈추지 못했기 때문이다.
  • verify-done.sh는 킷 레포 자신의 게이트다. 배포물에 실리지 않는다. 이 글의 게이트 수정은 킷 개발 과정을 지키는 것이고, 소비자 프로젝트에 실리는 것은 checklist 도구와 훅이다.