이 글은 mini-harness의 후속편이다. 코드를 이식한 이야기가 아니라, 거기서 확인한 원리가 실제 팀 환경에서 무엇으로 바뀌었는지를 쓴다.
학습용 하네스에서는 모델의 「다 했습니다」를 테스트로 검증했다. 팀에서 필요한 건 한 단계 더 바깥이었다. 「훅이 설치됐다」, 「정책이 등록됐다」, 「테스트가 통과했다」는 말까지 믿지 않고, 실제 실행 경로가 이어져 있는지를 검증해야 했다.
1. 하네스를 만들고 남은 질문
앞 글에서 에이전트 하네스를 바닥부터 만들었다. 결론은 한 줄이었다 — 모델은 엔진이고, 하네스는 나머지 전부다.
그중 제일 마음에 들었던 장치가 검증 게이트였다. 모델이 「다 했습니다」라고 선언하면 하네스가 독립적으로 테스트를 돌리고, 실패하면 그 실패 메시지를 사용자 요청처럼 대화에 다시 넣어 루프를 재개한다. 모델의 자기 보고와 하네스의 판정이 갈라지는 순간을 두 눈으로 보는 게 그 레포에서 제일 짜릿한 지점이었다.
그리고 질문이 하나 남았다.
이 구조를 실제 팀 개발 환경에 붙이면 무엇이 달라질까?
마침 회사에서 그걸 해볼 일이 생겼다. 팀에는 AI가 지켜야 할 코딩 규칙이 이미 문서로 정리돼 있었다. 일부는 현실과 어긋나 있었다 — 문서가 요구하는 라벨이 레포에 없어서 그 규칙을 지키려는 사람만 막히거나, 명시된 프레임워크 버전이 몇 세대 전이거나. 하지만 더 근본적인 문제는 따로 있었다. 옳은 규칙조차, 읽지 않으면 아무 일도 일어나지 않는다. 규칙을 어겼는지는 맨 끝에서 사람이 눈으로 잡고 있었다.
답은 알고 있었다. 검증 게이트를 앞으로 당기면 된다. 그런데 막상 붙여보니, 학습용에서 검증하던 것과 팀에서 검증해야 하는 것이 달랐다. 이 글은 그 차이에 대한 것이다.
2. 직접 만들지 않은 것 — 경계부터 긋는다
먼저 안 만든 것부터 말하는 게 정확하다. 모델 호출 루프를 다시 만들지 않았다.
팀은 이미 에이전트 CLI를 쓰고 있었다. 모델도, 도구도, 「도구 호출 → 결과 되먹임 → 다시 호출」하는 루프도 그 런타임이 제공한다. 컨텍스트 압축이나 API 예산 상한처럼 앞 글에서 다뤘던 것들도 마찬가지로 런타임 영역이다.
내가 붙인 건 그 바깥의 정책·검증·관측 계층이다. 엔진은 이미 돌고 있었고, 나는 안전벨트와 계기판을 단 셈이다.
이 경계를 처음에 안 그으면 일이 이상하게 커진다. 「하네스를 만든다」는 말이 「에이전트를 다시 만든다」로 미끄러지기 쉽고, 그러면 런타임이 이미 잘하는 걸 다시 짜느라 정작 필요한 층을 못 만든다.
개념이 무엇으로 바뀌었나
| 학습용 하네스에서 맡았던 역할 | 팀 환경에서 대응한 장치 |
|---|---|
| 모델·도구 호출 루프 | 에이전트 런타임에 맡김 — 다시 만들지 않는다 |
| 올바른 행동 안내 | 스킬 · 규칙 문서 |
| 행위 가로채기 | 훅 |
| 코드 내용 판정 | lint · typecheck |
| 제한된 행위 가드 | 알려진 보호 경로와 위험 명령을 훅이 판정한다. 컨테이너나 권한 분리 같은 완전한 샌드박스는 아니다 |
| 실패 결과 되먹임 | 차단 사유와 가능한 대안을 함께 반환 |
| 독립적인 완료 검증 | 테스트 · CI — 정책 validator · 픽스처 · 변이 테스트 · 실제 레포 대상 검증 |
| 하네스 자체 검증 | 훅 코드뿐 아니라 등록 경로 · 실행 권한 · CI 배선까지 |
| 실행 트레이스 | 관측 로그로 「위반 0건」과 「훅 미실행」을 구분 |
| 운영 안전장치 | 처음엔 기록만, 만료일 뒤 자동으로 차단 전환 |
| 컨텍스트 압축 · API 예산 | 런타임 영역이라 직접 구현하지 않음 |
넷째 줄은 정직하게 적어둘 필요가 있다. 앞 글에서 작업 디렉터리 제한이나 명령 차단은 진짜 격리가 아니라고 썼는데, 그건 여기서도 그대로다. 훅은 알려진 행위를 가로채는 장치지 프로세스 격리가 아니다.
처음엔 실제로 뚫렸다 — 쓰기 도구로 막은 경로가 셸 리다이렉트로는 그냥 통과했다. 막는 방법은 있었지만, 「경로가 명령에 나오면 막는다」로 하면 그 경로를 읽기만 하는 명령까지 막힌다. 그래서 「경로가 등장하는가」가 아니라 **「그 경로에 쓰는가」**를 보도록 고쳤고, 케이스의 절반은 지금도 막으면 안 되는 쪽이다.
그래도 원리적으로 못 보는 것이 남는다. 인터프리터 안에서 파일을 만지는 것(python3 -c "open(...)"), 실행 전에는 값을 알 수 없는 명령 치환으로 경로를 조립하는 것. 이건 버그가 아니라 경계고, 그 층은 lint와 리뷰가 맡는다. 고칠 수 있는 결함과 고칠 수 없는 경계를 같은 목록에 두지 않는 게 중요했다.
아래 세 절은 이 표에서 학습용에 없었거나, 있었어도 의미가 달라진 칸들에 대한 이야기다.
3. 실제로 만든 것 — 문서를 데이터로 내리고, 훅에 판정을 맡긴다
규칙을 문서에서 정책 파일로
가장 먼저 기계가 판정할 수 있는 규칙을 문서 밖으로 꺼냈다. 문서는 「지켜라」라고 말할 수 있지만, 실제로 지켰는지는 확인하지 못한다.
옮긴 형태는 코드가 아니라 데이터였다. 한 줄에 한 규칙, 패턴|거부 사유 두 칸.
packages/shared-ui/|공용 컴포넌트입니다. 수정하지 말고 호출부에서 props로 처리하세요
.github/workflows/|CI 게이트입니다. 끄려면 PR로 사유를 남기세요새 규칙을 추가할 때마다 훅 구현을 수정하지 않아도 된다. 정책 파일에 한 줄을 넣으면 같은 validator와 같은 실행 경로를 그대로 재사용한다.
차단은 반드시 대안을 준다 — 그리고 그걸 CI가 강제한다
앞 글에서 예외를 삼키지 않고 실패도 결과로 되돌려준다고 썼다. 팀 버전에서는 한 걸음 더 갔다. 차단 사유에 다음에 무엇을 하면 되는지가 없으면 CI가 그 정책 줄을 반려한다.
이유는 단순하다. 「차단됨」만 주면 모델은 우회를 시도한다. 실제로 거부 사유에 대안을 적어두니 모델이 스스로 다른 방법을 제안했다. 에러 메시지는 모델이 스스로 고치는 연료다. 그리고 사람에게도 같다 — 대안 없이 막히면 사람은 가드레일을 끈다.
가드레일이 자기 자신도 막게 한다
막힌 모델에게 가장 빠른 길은 규칙을 지키는 게 아니라 규칙을 지우는 것이다. 커밋 게이트가 막으면 게이트 정책 파일을 고치는 게 더 빠르다.
그래서 훅 등록 파일 · 정책 파일 · CI 워크플로 자체를 정책의 보호 대상에 넣었다. 그 세 줄이 없으면 이건 가드레일이 아니라 제안이다.
대신 정상적인 변경 경로는 열어뒀다. 자동화 세션의 직접 수정은 막되, 사람이 브랜치를 파고 PR로 올리는 길은 그대로다. 자기보호는 규칙을 변경 불가능하게 만드는 장치가 아니라, 조용한 우회를 기록이 남는 변경으로 바꾸는 장치다.
⚠️ 정책 파일 마지막 줄에 개행이 없으면
while read가 그 줄을 버린다. 자기보호 규칙이 마지막 줄에 있었기 때문에 방어선 전체가 오류 없이 사라질 수 있었다. 그래서 훅과 외부 검사기 양쪽에서 마지막 줄까지 읽혔는지 검증한다.
여기까지가 한 번의 도구 호출에서 일어나는 일
flowchart TD
A["도구 실행 요청"] --> B["훅 호출 · invoke 기록"]
B --> C["정책 판정"]
C -->|"해당 규칙 없음"| G["도구 실행"]
C -->|"위반"| D{"관측 기간인가?"}
D -- 예 --> E["차단 예정 사유 기록"]
E --> G
D -- 아니오 --> F["차단 사유와 대안 반환"]
F --> A
훅이 invoke를 판정보다 먼저 남긴다는 점과, 관측·차단이 같은 판정 경로를 쓴다는 점은 뒤의 5·6절에서 다시 쓰인다.
4. 검증 게이트를 한 층 더 — 「테스트가 통과했다」도 안 믿는다
여기서부터가 학습용과 진짜로 갈라지는 지점이다.
훅마다 단위 테스트를 붙였다. 정상 통과 · 위반 탐지 · 판정 불가 세 가지 케이스를 고정하고, 엣지케이스를 변이 테스트로 짰다. 순진한 구현이 반드시 틀려야 하고, 올바른 구현이 반드시 맞아야 통과다. 한쪽만 보면 그 케이스가 버그를 실제로 잡는지를 증명하지 못한다.
여기까지는 앞 글의 연장이다. 문제는 그다음이었다.
셋 다 초록불인데 훅은 한 번도 안 돈다
훅 등록 파일의 실행 경로를 존재하지 않는 경로로 바꿔놓고 검사들을 돌려봤다.
| 검사 | 결과 | 왜 못 잡나 |
|---|---|---|
| 플러그인 매니페스트 검증 | ✅ 통과 | 매니페스트 형식만 본다 |
| 설치 상태 점검 스크립트 | ✅ 통과 | 버전과 출처만 봤다 |
| 훅 단위 테스트 전체 | ✅ 통과 | 훅을 직접 부르므로 등록과 무관하다 |
세 검사가 전부 초록불인데 실제 세션에서는 훅이 한 번도 실행되지 않는다. 이게 이 프로젝트를 시작하게 만든 원래 고장과 정확히 같은 모양이었다 — 난간을 만들었는데 전원이 안 꽂혀 있는 상태.
그래서 검사 축을 하나 더 만들었다. 등록 축이다.
- 등록된 실행 경로가 실재하는가
- 그 파일에 실행 권한이 있는가 (셔뱅으로 실행되므로 없으면 런타임에 죽는다)
- 등록 파일이 파싱되는가, 명령이 비어 있지 않은가
「직접 부르면 된다」와 「런타임이 부른다」는 다른 검사다. 단위 테스트가 구현을 보고, 등록 검사가 배선을 본다. 하나만 있으면 반쪽이다.
학습용에서는 이 축이 필요 없었다. 내가 짠 루프가 내가 짠 도구를 직접 불렀으니까. 남의 런타임 위에 올라가는 순간, 배선이 별도의 검증 대상이 된다.
5. 「0건」의 두 가지 의미
주간 리포트에 위반 0건이 떴다고 하자. 둘 중 하나다.
- 정말 아무도 규칙을 안 어겼다
- 훅이 죽어 있다
그리고 화면에 찍히는 숫자는 똑같다. 이 프로젝트를 시작하게 만든 고장들이 전부 두 번째였다는 걸 생각하면, 이 구분이 안 되는 관측은 관측이 아니다.
해결은 기록하는 시점을 옮기는 것이었다. 훅이 맨 앞에서, 판정과 무관하게 「돌았다」는 사실을 한 줄 남긴다.
{"ts":"...","hook":"protected-paths","event":"invoke","version":"...","mode":"observe"}
{"ts":"...","hook":"protected-paths","event":"decision","decision":"deny","rule":"...","ms":12}
{"ts":"...","hook":"verify-gate","event":"done","ms":840}판정이 났을 때만 남기면 이 구분이 아예 불가능해진다. invoke가 한 건이라도 있으면 훅이 실제로 실행됐다는 증거가 된다. 그 상태에서 deny 판정이 0건이면 감지된 위반이 없었던 것이다.
다만 반대 방향은 성립하지 않는다. invoke 자체가 0건이면 훅이 죽었거나, 애초에 실행될 기회가 없었거나 둘 중 하나다 — 이 숫자만으로는 구분할 수 없다. 에이전트 사용 기록처럼 분모가 따로 있을 때만 「사용은 했는데 invoke가 0건 = 배선이 끊겼다」까지 말할 수 있다.
같은 줄에 훅 버전과 모드도 남긴다 — 「예전엔 됐는데」를 나중에 구분하려면 그게 있어야 한다.
관측인가 감시인가 — 기록의 대상은 사람이 아니다
여기서 한 가지를 먼저 정해야 했다. 이 로그는 결국 팀원들의 기록이다.
잘못 만들면 관측이 아니라 감시가 된다. 그리고 감시가 되는 순간 다음 절과 정확히 같은 결말이 난다 — 사람이 가드레일을 끈다. 오탐은 일을 막아서 끄게 만들고, 감시는 감시당하기 싫어서 끄게 만든다. 원인만 다를 뿐 도착지는 같다.
그래서 셋을 정해뒀다.
① 기록은 각자 로컬에, 그리고 레포 밖에 쌓인다. 기본 경로를 작업 디렉터리 밖인 홈 디렉터리 아래에 둔다 — 기본 설정에서는 작업물과 함께 커밋되지 않는다. 레포 내부 경로로 변경되는 경우에 대비해 무시 목록도 2차 방어로 둔다. 팀 단위로 모으는 건 기본값이 아니라 스위치를 따로 켜야 하는 별도 단계다.
② 집계 단위는 사람이 아니라 규칙이다. 차단 사유에는 사용자가 실제로 건드린 경로가 들어간다. 그래서 집계에 쓰는 식별자는 정책 파일의 패턴 하나뿐이다 — 그건 우리가 쓴 규칙이지 사용자 데이터가 아니다. 코드 내용 · 파일 경로 · 브랜치명 · 커밋 메시지는 올라가지 않는다. 나오는 건 「어느 규칙이 몇 번 걸렸나」이지 「누가 몇 번 어겼나」가 아니다.
③ 그 숫자를 어느 쪽으로 읽을지를 미리 적어뒀다. 특정 규칙이 반복해서 걸리면 그건 사람이 규칙을 못 지킨다는 뜻이 아니라 규칙이나 대안 문구가 틀렸다는 신호로 읽는다. 사람들이 직접 알려준 비활성화 사례가 반복되면 더 강한 신호다. 다만 이 정보는 자동으로 수집하지 않는다. 읽는 방향을 먼저 정해두지 않으면 로그는 사람을 평가하는 자료로 오해되거나 전용되기 쉽다.
정직하게 남긴 구멍 하나 — 누가 어떤 규칙을 꺼뒀는지는 자동으로 모을 경로가 없다. 개인 설정 파일은 버전 관리 밖이라 보이지 않는다. 그런데 그걸 수집하려면 정확히 ②에서 안 하기로 한 일을 해야 한다. 지금은 사람이 알아채는 수밖에 없고, 그게 맞는 교환이라고 보고 있다.
관측 자체가 비싸져도 사람은 끈다. 기록은 한 줄 append로 끝내고 집계는 나중에 따로 돈다.
6. 왜 곧바로 차단하지 않았는가
이번처럼 팀 코딩 가드레일을 처음 굴리는 구간에서는 구멍 하나보다 반복되는 오탐이 더 비쌌다. 정상 작업을 몇 번 잘못 막으면 사람은 규칙 하나를 고치는 대신 가드레일 전체를 끈다.
그래서 첫 2주는 차단하지 않고, 실제로 켜졌다면 무엇이 막혔을지만 기록해 오탐 여부를 판단하기로 했다.
설계에서 지킨 선이 셋이다.
① 정책 판정은 하나만 둔다. 관측과 차단이 같은 정책 매칭 코드를 쓰고, 판정 이후의 처리만 한 곳에서 갈린다. 경로를 따로 만들면 관측한 코드와 실제 차단하는 코드가 달라진다.
② 만료일이 지나면 자동으로 차단한다. 누군가 다시 바꿔야 하는 상태 플래그는 그대로 방치되기 쉽다. 날짜를 기준으로 전환하면 연장할 때 반드시 커밋이 남는다.
OBSERVE_UNTIL=2026-10-01 # 이 날짜까지 관측. 지나면 차단.③ 조용히 통과시키지 않는다. 관측 모드에서도 무엇이 막혔을지를 보여준다. 개수만 세는 관측은 쓸모가 없다 — 사람이 오탐 여부를 판정할 수 없으면 2주 동안 배우는 게 없다.
⚠️ 관측 모드를 만들면서 밟은 함정 둘
- 날짜 형식이 잘못되면 차단 쪽으로 처리한다. 문자열 비교라 오타 하나로 비교가 늘 참이 되어 영원히 관측 모드가 된다 — 가드레일이 꺼진 채 켜진 것처럼 보이는 실패다.
- 관측 모드에서는 명시적인 허용을 반환하지 않는다 —
allow를 돌려주면 판정 유보가 아니라 승인이 된다. 런타임의 권한 확인까지 건너뛰어 훅이 아예 없을 때보다 더 허용적이 된다.
모델에게 보낸 경고가 세션에 닿지 않는 경우가 있어 같은 내용을 표준 오류에도 출력했다. 둘 다 놓쳐도 append 방식의 로컬 로그는 남는다. 관측의 최종 원본은 메시지가 아니라 로그다.
7. 결론 — 하네스는 규칙을 적는 장치가 아니다
학습용 하네스에서 검증한 건 하나였다. 모델의 말 vs 테스트 결과.
팀에서는 검증해야 할 「말」이 훨씬 많았다.
| 믿지 않아야 할 말 | 무엇으로 확인했나 |
|---|---|
| 「훅이 설치됐습니다」 | 등록 경로 실재 · 실행 권한 · 배선 검사 |
| 「위반이 0건입니다」 | 판정과 무관한 실행 기록(invoke) |
| 「테스트가 통과했습니다」 | 변이 테스트 — 순진한 구현은 반드시 틀려야 한다 |
| 「규칙이 문서에 있습니다」 | 문서가 요구하는 대상이 실재하는지 CI가 대조 |
전부 같은 모양이다. 어떤 주체가 자기 상태를 보고하고, 그 보고와 실제 상태 사이에 틈이 있다. 모델이든, 설정 파일이든, 초록불이든 마찬가지다.
그래서 이 프로젝트에서 한 일은 규칙을 늘리는 게 아니었다. 이미 규칙은 충분히 있었다. 부족한 건 확인이었고, 하네스는 그 확인을 자동화하는 구조였다.
앞 글 마지막에 이렇게 썼다 — 모델의 자기 보고와 하네스의 판정이 갈라지는 순간을 보는 게 제일 짜릿했다고. 팀에서 같은 걸 다시 겪었다. 다만 이번엔 갈라진 쪽이 모델이 아니라 내가 짜 놓은 시스템 자신이었다.