AI가 읽은 값은 어디로 가는가
.env는 API 키와 비밀번호를 한곳에 모아 두는 파일이다. API 키는 프로그램이 외부 서비스에 들어갈 때 내미는 출입증이라고 보면 된다. 파일을 왜 코드와 따로 두는지는 앞선 글에서 다뤘다. 이 글은 그다음 질문을 다룬다. 그 파일을 내 AI 코딩 도구가 열면 어떻게 되는가.
Claude Code와 Codex는 내 컴퓨터에서 내 계정 권한으로 명령을 실행한다. 내가 열 수 있는 파일은 AI도 열 수 있다는 뜻이다. 그리고 AI는 명령 결과를 읽어야 다음 행동을 정할 수 있으므로, 결과는 대화에 들어가 모델을 돌리는 회사 서버로 보내진다. Anthropic의 Claude Code 데이터 사용 문서도 사용자 프롬프트와 모델 출력 전부가 네트워크로 전송된다고 적고 있다. AI가 .env를 화면에 찍으면 비밀번호가 그 대화의 일부가 된다.
보내진 뒤의 보관 기간은 계정 종류와 설정에 따라 다르다. 같은 문서 기준으로 개인 요금제(Free·Pro·Max)는 모델 개선에 데이터 제공을 허용하면 5년, 허용하지 않으면 30일이고, 팀·엔터프라이즈·API 계정은 기본 30일이다. 설정은 claude.ai의 개인정보 설정에서 언제든 바꿀 수 있다. Codex도 명령 결과를 모델에 보내야 동작하는 구조는 같고, 보관 기준은 OpenAI 쪽 정책을 따로 확인해야 한다.
서버만의 문제도 아니다. Claude Code는 끊긴 대화를 이어 쓸 수 있도록 대화 기록을 내 컴퓨터의 ~/.claude/projects/ 폴더에 암호화하지 않은 글자 그대로 기본 30일 보관한다. 화면에 한 번 찍힌 비밀번호는 그 파일에도 남는다.
읽는 것과 보는 것은 다르다
그렇다고 AI가 .env를 못 쓰게 할 수는 없다. 프로그램은 키를 읽어야 돌아간다. 여기서 구분할 것이 두 가지다. 프로그램이 값을 읽는 것과 AI가 값을 보는 것이다.
AI가 `source .env && python3 run.py`를 실행하면 .env를 여는 것은 run.py라는 프로그램이다. 비밀번호는 내 컴퓨터에서 외부 서비스로만 가고, AI에게 돌아오는 것은 프로그램이 화면에 찍은 "완료" 같은 출력뿐이다. `curl -H "Authorization: Bearer $SLACK_TOKEN"`처럼 명령에 변수 이름만 쓰는 경우도 같다. 이름을 실제 값으로 바꾸는 일은 내 컴퓨터의 셸이 한다. 셸은 터미널, 즉 명령을 글자로 치는 창에서 그 명령을 받아 실행하는 프로그램이다.
새는 것은 AI가 값을 화면에 내보내는 명령을 실행할 때다. `cat .env`로 파일 내용을 그대로 찍거나, `echo $SLACK_TOKEN`으로 변수 값을 찍거나(변수는 이름표를 붙여 값을 담아 둔 칸이다), 저장된 값 전체를 나열하는 `printenv`를 실행하는 경우다. 막아야 할 대상은 파일 접근 전체가 아니라 이 출력이다.
.env ──> python3 run.py ──> 외부 서비스 (값이 가는 길)
│
└──> 화면 출력 "완료" ──> AI ──> 모델 서버 (AI가 보는 것)가려 주는 것은 약속이지 장치가 아니다
이 질문을 처음 한 날, Claude는 이미 값을 가리고 있었다. 파일을 읽을 때 값 자리를 <set>으로 바꾸는 명령을 붙여 키 이름만 보여 줬다. 좋은 습관이지만 이것은 AI가 스스로 지키는 규칙이다.
규칙은 두 경우에 깨진다. 하나는 AI의 실수다. 고장 원인을 찾는 디버깅 중에 값을 확인하겠다며 그대로 찍을 수 있다. 다른 하나는 프롬프트 인젝션이다. AI가 읽은 웹페이지나 문서 안에 숨겨 둔 지시문이 AI를 속여 원래 하지 않을 행동을 시키는 공격을 말한다. 두 경우 모두 AI의 선의로는 막을 수 없다.
그래서 이 글의 답은 이렇다. Claude와 Codex의 의도를 믿을지 말지를 고민하기보다, 의도와 상관없이 값이 나가지 못하는 구조를 먼저 만든다. 사람 직원에게 금고 비밀번호를 알려 주지 않고 금고를 여는 권한만 주는 것과 같다.
1단계: Claude Code의 .env 읽기 금지 규칙
가장 쉬운 첫 겹은 Claude Code가 공식으로 제공하는 권한 규칙이다. 내 홈 폴더의 .claude/settings.json 파일에 아래 내용을 넣는다. settings.json은 Claude Code의 동작을 정하는 설정 파일이다. 파일이 이미 있으면 permissions 안의 deny 목록에 두 줄만 더한다.
공식 문서에 따르면 이 규칙은 Claude의 파일 읽기 도구뿐 아니라 셸에서 쓰는 cat·head·tail·sed 같은 파일 명령과 `< file` 같은 입력 연결에도 적용된다. `//**/`는 컴퓨터 어디에 있는 .env든 해당된다는 뜻이다. `.env.*` 줄은 .env.local 같은 파일을 막는데, 공유용 견본 파일인 .env.example도 함께 막힌다.
한계도 문서에 적혀 있다. 파일 이름을 적지 않고 폴더 전체에서 글자를 찾는 `grep -r`이나, 파이썬·노드(둘 다 프로그래밍 언어다)로 짠 스크립트가 스스로 파일을 여는 경우에는 적용되지 않는다. 운영체제 수준에서 모든 프로그램의 접근을 막으려면 샌드박스를 켜라고 안내한다. 샌드박스는 AI가 실행하는 명령을 정해진 울타리 안에서만 돌게 하는 격리 장치다. 또 이 규칙은 Claude Code 설정이라 Codex에는 적용되지 않는다.
{
"permissions": {
"deny": [
"Read(//**/.env)",
"Read(//**/.env.*)"
]
}
}2단계: 훅 — 명령이 실행되기 직전의 검문소
훅은 AI 도구가 특정 순간에 내 스크립트를 자동으로 부르게 하는 연결 고리다. 그중 PreToolUse는 AI가 명령이나 도구를 실행하기 바로 직전에 불린다. 스크립트는 AI가 무엇을 실행하려는지 받아 보고, 막아야 하면 거부 신호를 돌려준다. 그러면 명령은 실행되지 않고 거부 사유가 AI에게 전달된다. Claude Code와 Codex는 이 거부 신호의 형식이 같아서 스크립트 하나를 둘 다에 걸 수 있다.
아래는 규칙 1단계가 놓치는 경우 중 가장 흔한 두 가지만 막는 짧은 예제다. 저장된 값을 통째로 나열하는 env·printenv 단독 실행, 그리고 이름에 TOKEN·SECRET·PASSWORD·_PW·_KEY가 들어간 변수를 echo나 printf로 찍는 명령이다. ~/.claude/hooks/block-env-print.py로 저장한다. 맥에 python3가 설치돼 있어야 한다.
import json, re, sys
cmd = json.load(sys.stdin).get("tool_input", {}).get("command", "")
secret = r"\$\{?[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|_PW|_KEY)[A-Z0-9_]*"
if re.fullmatch(r"\s*(env|printenv|export -p)\s*", cmd) or re.search(r"(echo|printf)[^|;&]*" + secret, cmd):
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "비밀 값을 화면에 찍는 명령이라 막았습니다. 값은 쓰기만 하고 출력하지 마세요.",
}}, ensure_ascii=False))
훅을 Claude Code와 Codex에 거는 법
Claude Code는 같은 settings.json에 hooks 항목을 더한다. matcher는 어떤 도구에 검문소를 세울지 고르는 칸이고, Bash는 셸 명령 실행 도구다. 1단계의 permissions와 한 파일에 나란히 둔다.
Codex는 ~/.codex/hooks.json에 같은 모양으로 적는다. 다른 점이 하나 있다. Codex는 사용자가 훅의 정의를 직접 확인하고 신뢰해야 실행한다. Codex를 열고 /hooks 명령에서 새로 추가된 항목을 신뢰로 표시하기 전까지는 훅이 건너뛰어진다.
건 뒤에는 한 번 시험한다. 이 예제에 표본 명령 10개를 넣어 보니 값을 찍는 명령 5개는 막히고, 값을 쓰기만 하거나 글자 수만 보거나 비밀과 무관한 명령 5개는 통과했다. 그중 여섯 개를 아래 표에 옮겼다. 실제 도구에서는 AI에게 printenv를 실행해 달라고 해서 막히는지 보면 된다.
| 명령 | 결과 | 이유 |
|---|---|---|
| printenv | 막음 | 저장된 값 전체를 나열한다 |
| echo $SLACK_TOKEN | 막음 | 토큰 값을 화면에 찍는다 |
| printf "%s" "$OPENAI_API_KEY" | 막음 | API 키 값을 화면에 찍는다 |
| source .env && python3 run.py | 통과 | 프로그램이 값을 쓰기만 한다 |
| curl -H "Authorization: Bearer $SLACK_TOKEN" … | 통과 | 명령에는 변수 이름만 있다 |
| echo ${#SLACK_TOKEN} | 통과 | 값이 아니라 글자 수만 찍는다 |
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.claude/hooks/block-env-print.py\""
}
]
}
]
}
}{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.claude/hooks/block-env-print.py\""
}
]
}
]
}
}실제로 건 훅: 막는 것, 통과시키는 것, 못 막는 것

예제는 입구다. 실제로 쓰는 훅은 같은 원리로 경우를 넓힌 파이썬 스크립트 하나이고, 2026년 9월 11일 Claude Code와 Codex에 함께 걸었다. 규칙을 고칠 때마다 막아야 할 명령 39건, 통과해야 할 명령 51건, 도구별 경우 11건, 해석 실패 1건까지 102건을 한꺼번에 돌려 양쪽 방향이 모두 맞는지 확인한다. 막을 때 권하는 대체 명령도 통과 목록에 넣어 두어, 안내한 대로 고쳤는데 또 막히는 일이 없게 했다.
해석 실패 1건은 일부러 통과시키는 경우다. 스크립트가 명령을 해석하지 못하면 막지 않고 실행을 허용한다. 모든 애매한 명령을 막으면 작업이 멈추기 때문에 고른 절충이고, 그만큼 구멍이다.
걸고 나서 이틀 동안 이 훅은 내 AI를 세 번 막았다. 9월 12일에는 API 키 변수를 printf로 찍으려던 명령과, 파이썬 코드 안에서 .env를 열던 명령이 막혔다. AI는 거부 사유를 읽고 값을 찍지 않는 형태로 바꿔 다시 실행했다. 9월 13일에는 이 글을 쓰려고 지난 대화 기록을 검색하던 코드가 막혔다. 검색어 목록에 ".env"라는 글자가 들어 있었을 뿐 실제로 .env를 열지는 않았으니, 막지 않아도 될 것을 막은 오탐이다. AI는 코드를 스크립트 파일로 옮겨 실행했다. 이 마지막 장면이 훅의 한계를 그대로 보여 준다. 스크립트 파일 안에서 무엇을 하는지는 명령 한 줄만 보고는 알 수 없다. 아래 표의 cat·grep·sed·jq·cut은 모두 파일 내용을 화면에 보여 주거나 일부만 걸러 내는 명령이다.
| 구분 | 예 |
|---|---|
| 막음 | cat·head·grep·sed·jq로 .env 내용 출력, 파일 읽기 도구로 .env 열기, env·printenv 단독 실행, 비밀 이름 변수 echo, 파이썬·노드 한 줄 코드가 .env를 여는 경우, 제외 조건 없이 폴더 전체에서 비밀 이름을 grep |
| 통과 | source .env 뒤 프로그램 실행, 값 자리를 가리는 sed, 줄 수만 세는 grep -c, 키 이름만 뽑는 cut·jq keys, 파일 크기·수정일 확인, 결과를 파일로 저장 |
| 못 막음 | 스크립트 파일이 스스로 값을 출력, set -x나 curl -v처럼 명령이 과정을 자세히 찍는 옵션, 파일을 다른 이름으로 복사한 뒤 읽기, 해석 실패 시 통과, Codex의 웹 검색처럼 훅을 거치지 않는 도구 |
100%가 아니어도 먼저 걸어야 하는 이유와 다음 순서
훅은 명령을 글자로만 본다. 그래서 막는 범위가 넓어질수록 오탐이 늘고, 우회하려는 AI를 끝까지 막지는 못한다. 그래도 사고는 대부분 악의보다 습관에서 난다. 디버깅하다 무심코 찍는 cat .env, 설정을 확인하겠다며 실행하는 printenv가 그 습관이고, 이 둘을 막는 데는 규칙 두 줄과 짧은 스크립트면 된다.
다음 순서는 세 가지다. 첫째, 이미 대화에 값을 찍은 적이 있다면 그 키를 새로 발급받는다. 대화 기록에서 지우는 것보다 키를 바꾸는 것이 확실하다. 둘째, 프로젝트마다 그 프로젝트에 필요한 키만 담은 .env를 따로 둔다. 키 수십 개가 든 파일 하나를 모든 프로젝트가 불러오면 한 번의 실수로 그 수십 개가 한꺼번에 나간다. 셋째, 쓰는 요금제의 데이터 설정을 한 번 확인한다.
오늘 할 일은 하나다. ~/.claude/settings.json에 .env 읽기 금지 규칙 두 줄을 넣고, Claude Code에게 "cat .env 실행해 줘"라고 요청해 막히는지 본다.
