[Claude Code] 막았다고 믿은 파일이 그대로 읽혔다: 권한 설정의 deny 글롭 구멍
들어가며: AI 에이전트에게 저장소를 맡기면서
요즘 나는 AI 에이전트가 사람 개입 없이 여러 사이클을 스스로 돌며 코드를 고치고 커밋까지 진행하는 워크스페이스를 운영한다. 에이전트가 무인으로 파일을 읽고 쓰는 이상, 가장 먼저 정해야 할 것은 "무엇을 못 읽게 할 것인가"다. 그 목록 맨 위에는 언제나 .env가 있다. API 키, 토큰, 접속 정보 같은 크리덴셜이 여기 모이고, 이게 한 번이라도 대화 컨텍스트에 평문으로 들어오면 그 순간 그 키는 사실상 유출된 것으로 봐야 한다.
Claude Code는 이런 접근 통제를 settings.json의 permissions 항목으로 선언한다. 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/.env나 a/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 쪽은, 실측만이 유일한 검증이다.
댓글
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.