AI

codex 리뷰어가 세션이 쓴 .git/config 를 읽고 있었다

GIT_CONFIG_* 로 덮어써도 textconv·filter·gpg.program 이 남아, 오케스트레이터가 diff 를 직접 만들어 넘기도록 바꿨다

한 줄로

AI 구현 세션이 만든 변경은 codex 리뷰어가 검사한다. 그런데 리뷰어가 돌리는 git 이, 검사받는 그 세션이 고칠 수 있는 /loop/work/.git/config 를 그대로 읽고 있었다. 설정 키를 환경변수로 덮어쓰는 것만으로는 textconv·filter·gpg.program 경로가 남았다. 그래서 검사할 diff 를 오케스트레이터가 --no-ext-diff --no-textconv 로 직접 만들어 넘기게 바꿨고, 컨테이너에서 쓸 수 없는 codex 샌드박스 대신 리뷰 전후의 저장소 지문을 비교한다.

배경: 컨테이너에 격리한 개발 루프

auto-trading-v2 는 사람이 매번 개입하지 않고 도는 개발 루프로 만든다. 구현 세션이 코드를 쓰고, 게이트 1(테스트)과 게이트 2(리뷰어)를 통과해야 다음으로 넘어간다.

  • 구현 세션: loop/drive.sh:389 의 claude -p
  • 게이트 2 리뷰어: loop/orchestrator/cli.py:117

같은 호스트에 실전 증권사(KIS) 키, 인증 없는 실주문 API, 1Password 토큰이 함께 있다. 그래서 루프는 Docker 컨테이너 안에 격리해서 돌린다(결정 D6). 호스트의 Claude 설정 디렉터리도 마운트하지 않는다.

Phase 0 PR #1 에서는 컨테이너 안에서만 드러나는, 리눅스 전용 잠금 결함 2건을 찾아 고쳤다. 테스트는 호스트에서 1011건 통과, 컨테이너에서 1009건 통과·2건 스킵이었다. Claude·codex·bunny 세 리뷰어가 R6 에서 모두 승인해 병합했다. 그 뒤 R7~R12 라운드에서는 codex 리뷰어를 노리는 보안 지적을 처리했다. 이 글은 그 이야기다.

개념: git 설정은 실행 경로이기도 하다

git 설정 파일에는 값만 들어 있지 않다. 몇몇 키는 git 이 작업하다가 실행할 외부 프로그램을 가리킨다.

  • diff.<드라이버>.textconv — diff 를 그리기 전에 파일을 텍스트로 바꾸는 명령
  • filter.<드라이버>.clean / smudge — 파일을 저장소에 넣고 꺼낼 때 거치는 변환 명령
  • gpg.program — 서명을 만들거나 검증할 때 부르는 프로그램
  • diff.external — diff 생성 자체를 외부 프로그램에 맡기는 설정

따라서 저장소의 .git/config 를 쓸 수 있으면, 같은 저장소에서 git 을 돌리는 다른 프로세스가 무엇을 실행하고 무엇을 보게 될지를 정할 수 있다. 이 루프에서는 리뷰받는 쪽이 리뷰하는 쪽의 도구 설정을 쥐고 있는 모양이 된다.

실제로 겪은 것

지적: 리뷰어의 git 이 세션의 설정을 읽는다

구현 세션은 작업 폴더 /loop/work 에 쓸 수 있고, .git/config 도 여기에 들어 있다. codex 의 git 이 이 파일을 그대로 읽는다는 지적이 나왔다.

1차 대응: GIT_CONFIG_* 환경변수로 덮어쓰기

먼저 안전 설정 인자 _GIT_SAFE_ARGS 를 _git_args_as_env 로 바꿔, GIT_CONFIG_* 환경변수(GIT_CONFIG_COUNT·GIT_CONFIG_KEY_n·GIT_CONFIG_VALUE_n 으로 설정을 넘기는 방식)로 codex 에 넘겼다. 이렇게 넘긴 설정은 -c 와 같은 수준이라 저장소 설정보다 우선한다.

그런데 textconv·filter·gpg.program 경로가 아직 남는다는 지적이 다시 나왔다. 이 경로들이 남는 구체적인 이유는 정리 노트에 기록하지 않았다.

2차 대응: diff 를 리뷰어 바깥에서 만든다

막을 키를 하나씩 늘리는 대신 방향을 바꿨다. 검사 대상 diff 는 오케스트레이터가 --no-ext-diff --no-textconv 로 직접 만들어 codex 에 넘긴다.

  • --no-ext-diff — diff.external 같은 외부 diff 프로그램을 쓰지 않는다
  • --no-textconv — textconv 변환을 거치지 않는다

리뷰어가 세션이 건드린 저장소 설정으로 diff 를 만드는 것이 아니라, 오케스트레이터가 만든 텍스트를 받아서 본다.

샌드박스 대신 저장소 지문

codex 에 딸린 샌드박스(bwrap)는 컨테이너 안에서 동작하지 않았다. 샌드박스는 끄기로 했다. 그 대신 리뷰 전과 후에 저장소 지문(_repo_fingerprint)을 떠서, 둘이 다르면 그 리뷰 판정을 버린다. 리뷰하는 동안 저장소가 바뀌었다면 그 결과를 믿지 않는다는 뜻이다. 이 상태에서 테스트 1015건이 통과했다.

R7 지적은 8da169a 로 반영했고, 컨테이너 이미지는 d56ad42 로 다시 빌드했다.

받아들인 한계, 반박한 지적

모든 지적을 코드로 막지는 않았다.

errors="replace"(디코딩할 수 없는 바이트를 � 로 바꿈)에 대한 지적은 수용 한계로 판정했다.

  • 당시 검토에서는 소스의 인코딩 문제를 게이트 1(테스트)에서 드러나는 한계로 보았다.
  • 나머지 경우는 README 의 한계 절에 해당한다. 그 절의 제목은 “게이트는 경보선이지 벽이 아니다”이다.

“프로젝트 위협 모델이 세션에 이미 같은 권한을 준다”는 이유로 반박 처리한 후보도 있다. 세션이 이미 가진 권한이라면, 리뷰어 쪽에서 그 경로를 막아도 공격자가 할 수 있는 일은 줄지 않는다는 판단이다. 이렇게 정리해서 R12 까지 승인을 받았다.

정리

  • 리뷰받는 쪽이 쓸 수 있는 파일을 리뷰 도구가 설정으로 읽으면, 리뷰 결과도 그 쪽이 조종할 수 있다. .git/config 가 그런 파일이었다.
  • 환경변수로 설정을 덮어쓴 뒤에도 textconv·filter·gpg.program 경로가 남는다는 지적이 나왔다. 검사할 입력을 신뢰할 수 있는 쪽에서 만들어 넘기는 편이 경계가 분명하다.
  • 샌드박스를 쓸 수 없는 환경이라면, 리뷰 전후 지문을 비교해 오염된 판정을 버리는 사후 검출이 대안이 됐다.
  • 막지 않기로 한 지적에는 근거를 남겼다. 다른 게이트에서 드러나는지, 이미 위협 모델 안에 있는 권한인지가 그 근거다.