지식

[Claude Code] 막았다고 믿은 파일이 그대로 읽혔다: 권한 설정의 deny 글롭 구멍

들어가며: AI 에이전트에게 저장소를 맡기면서

요즘 나는 AI 에이전트가 사람 개입 없이 여러 사이클을 스스로 돌며 코드를 고치고 커밋까지 진행하는 워크스페이스를 운영한다. 에이전트가 무인으로 파일을 읽고 쓰는 이상, 가장 먼저 정해야 할 것은 "무엇을 못 읽게 할 것인가"다. 그 목록 맨 위에는 언제나 .env가 있다. API 키, 토큰, 접속 정보 같은 크리덴셜이 여기 모이고, 이게 한 번이라도 대화 컨텍스트에 평문으로 들어오면 그 순간 그 키는 사실상 유출된 것으로 봐야 한다.

Claude Code는 이런 접근 통제를 settings.jsonpermissions 항목으로 선언한다. allow에 명시적으로 허용할 동작을 적고, deny에 절대 막을 동작을 적는다. 그래서 나는 당연하게도 deny에 이렇게 적어 두었다.

{
  "permissions": {
    "deny": [
      "Read(**/.env)",
      "Read(**/.env.*)"
    ]
  }
}

**/.env — 어느 디렉토리에 있든 .env 이름의 파일은 Read 도구로 못 읽게 막겠다는 뜻이다. 규칙을 적어 넣고, 나는 이걸 방어선이라 믿었다. 그게 사고의 시작이었다.

증상: 막았다고 믿은 파일이 그대로 읽혔다

무인 자동화를 실제로 발사하기 전, 나는 권한 프로파일이 의도대로 동작하는지 확인하는 시운전을 한다. allow에 넣은 명령들이 프롬프트 없이 잘 도는지, deny에 넣은 것들이 실제로 막히는지 한 번씩 눌러 보는 절차다. 이번엔 deny 쪽도 눌러 보기로 했다. 프로젝트 루트에 있는 .env를 Read 도구로 읽어 보라고 시켰다.

막힐 줄 알았다. 그런데 파일 내용이 그대로 돌아왔다. 프로덕션에서 쓰는 API 키가 평문으로 대화 컨텍스트에 찍혔다. Read(**/.env)를 분명히 deny에 넣어 두었는데도.

이건 오탈자나 캐시 문제가 아니었다. 규칙은 로드되어 있었고 문법도 맞았다. 그런데도 정확히 막으려던 그 파일이 통과했다. 시운전을 발사 전에 배치해 둔 것이 이 사고를 그나마 사람이 지켜보는 구간에서 터지게 했다. 만약 이 규칙을 믿고 무인 구간에 들어갔다면, 에이전트가 어느 사이클에서 .env를 읽어 로그에 남겨도 아무도 몰랐을 것이다.

추적: 글롭이 정확히 무엇에 매치되는지 실측하다

머릿속으로만 "**는 모든 경로에 매치되니까 루트도 당연히 포함이겠지"라고 넘겼던 게 문제였다. 확인하지 않은 가정이었다. 그래서 이 글롭이 실제로 어떤 경로에 매치되고 어떤 경로에 매치되지 않는지를 직접 재현해 보기로 했다.

핵심 질문은 하나다. **/.env 패턴이 프로젝트 루트 바로 아래의 .env(즉 경로가 그냥 .env)에 매치되는가?

여러 글롭 구현에서 **는 "0개 이상의 디렉토리 세그먼트"를 뜻한다고 알려져 있지만, 실제 매처마다 **/가 앞에 붙었을 때 "디렉토리가 0개인 경우"까지 포함하느냐가 갈린다. 많은 구현에서 **/는 "슬래시로 끝나는 접두" 형태라, 최소 한 번은 디렉토리 경계를 요구한다. 즉 sub/.enva/b/.env에는 매치되지만, 앞에 디렉토리가 전혀 없는 최상위 .env에는 매치되지 않는다.

내가 실측으로 확인한 결과가 정확히 이랬다.

패턴: **/.env

  .env          → 매치 안 됨   ← 프로젝트 루트 직하. 바로 이게 구멍이었다
  ./.env        → 매치 안 됨   ← 같은 파일을 ./ 붙여 표기해도 마찬가지
  config/.env   → 매치됨
  a/b/.env      → 매치됨

하위 디렉토리의 .env는 전부 막혔다. 정작 크리덴셜이 실제로 놓이는 위치인 프로젝트 루트의 .env 하나만 그물을 빠져나갔다. 가장 흔한 배치가 유일한 사각지대였던 것이다.

원인: **/ 는 "루트 직하"를 포함하지 않는다

정리하면 원인은 글롭 시맨틱에 대한 잘못된 직관이다.

  • **/.env 는 "어떤 디렉토리 아래의 .env"를 의미한다.
  • 이때 "어떤 디렉토리"에 **빈 디렉토리(루트 그 자체)**가 포함되지 않는 매처에서는, 경로 문자열이 그냥 .env인 파일은 매치 대상에서 빠진다.
  • 그래서 하위의 .env는 다 막으면서, 정작 루트의 .env는 무방비가 된다.

deny 규칙에서 이 방향의 실수는 특히 위험하다. allow 규칙이 너무 좁으면 에이전트가 프롬프트에서 멈춰 서니 금방 눈에 띈다. 하지만 deny 규칙이 너무 좁으면 아무 일도 없이 조용히 통과할 뿐이고, 그 통과가 곧 유출이다. 실패가 시끄러운 쪽과 조용한 쪽은 검증 방식을 달리해야 한다.

해결: 경로 표기 변형을 전부 열거하다

근본적으로 이건 "하나의 파일을 가리키는 경로 문자열이 여러 개"라서 생기는 문제다. .env, ./.env, **/.env는 사람 눈엔 같은 대상이지만 매처에겐 서로 다른 패턴이다. 그래서 해결책은 한 표기에 기대지 않고, 같은 대상을 가리키는 표기 변형을 전부 deny에 나열하는 것이다.

{
  "permissions": {
    "deny": [
      "Read(.env)",
      "Read(./.env)",
      "Read(.env.*)",
      "Read(./.env.*)",
      "Read(**/.env)",
      "Read(**/.env.*)"
    ]
  }
}
  • Read(.env) / Read(./.env) — 프로젝트 루트 직하의 .env를, 상대 표기와 ./ 접두 표기 양쪽으로 막는다. 이번 구멍의 직접 봉합.
  • Read(.env.*) / Read(./.env.*) — 루트의 .env.local, .env.production 같은 변종까지 같은 방식으로.
  • Read(**/.env) / Read(**/.env.*) — 하위 디렉토리에 있는 모든 .env 계열은 기존대로 유지.

수정을 반영한 직후, 나는 같은 실험을 다시 했다. 이번엔 프로젝트 루트의 .env를 Read 도구로 읽으라고 시키자 곧바로 차단 메시지가 돌아왔다.

File is in a directory that is denied by your permission settings.

이제야 의도대로 막혔다. "규칙을 적었다"와 "규칙이 막는다"는 다른 사건이라는 걸, 같은 호출을 두 번 눌러 확인한 셈이다.

재발 방지: denylist는 실측으로 검증하고, 방어는 여러 겹으로

이 사고에서 내가 얻은 교훈은 두 가지다.

첫째, deny 규칙은 작성만으로 믿지 말고 실제 호출로 차단을 실측한다. 무인 자동화를 발사하기 전 시운전에 반드시 deny 테스트를 포함한다. allow만 테스트하면 이 부류의 실패는 영영 안 보인다. 앞서 말했듯 deny 실패는 조용하기 때문이다. "막히는지"를 눈으로 확인하는 유일한 방법은 실제로 막아야 할 동작을 시켜 보고 차단 메시지를 받아 보는 것뿐이다. 나는 이제 권한 시운전 체크리스트를 이렇게 나눠 둔다.

[ allow 시운전 ]  허용한 명령이 프롬프트 없이 실행되는가
[ deny  시운전 ]  막아야 할 동작이 실제로 차단 메시지를 내는가
                   ↳ .env 계열은 루트/하위/./접두 표기를 각각 시도

둘째, 접근 통제 한 겹만으로 크리덴셜을 지키려 하지 않는다. deny 목록을 아무리 촘촘히 짜도, 파일을 읽을 수 있는 통로는 Read 도구 하나가 아니다. 자동화가 프롬프트에서 멈추지 않게 하려고 나는 스크립트 실행이나 범용 인터프리터 실행을 allow에 넣어 두었는데, 이런 도구는 원리상 내부에서 임의의 파일을 열 수 있다. Read를 막아도 스크립트가 파일을 읽어 출력하면 그만이다.

그래서 진짜 방어선은 규칙 목록이 아니라 설계에 둔다. 크리덴셜이 필요한 작업은 전용 스크립트가 자기 안에서 .env를 로드(source)하도록 만들고, 에이전트에게는 그 스크립트를 실행할 권한만 준다. 에이전트는 키 값을 눈으로 보지 않고, 키는 스크립트 프로세스 안에서만 존재하다 사라진다. deny 규칙이 첫 번째 그물이라면, 이 "에이전트는 키를 다루지 않고 스크립트에 위임한다"는 원칙이 그물을 빠져나간 것을 받는 두 번째 층이다.

마치며

**/.env 한 줄로 안심했던 게 사고의 본질이었다. 규칙을 적는 순간의 만족감이 검증을 건너뛰게 만든다. 글롭은 사람이 읽는 의미와 매처가 계산하는 의미가 미묘하게 어긋날 수 있고, 그 어긋남이 하필 가장 중요한 파일의 가장 흔한 위치에서 터졌다.

접근 통제 규칙을 작성하는 사람에게 남기고 싶은 한 문장은 이것이다. 막았다고 믿지 말고, 막히는지 눌러 봐라. 특히 실패가 조용한 deny 쪽은, 실측만이 유일한 검증이다.

조회 1댓글 0

댓글

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.