이 글에 대해
Claude Code 설정 파일에는 몇 달째 deny 규칙 몇 줄이 들어 있었다. 설정 파일을 읽으려는 shell 명령을 정규식으로 막는 hook도 직접 만들어 붙여 뒀다. 이 정도면 된다고 생각했다.
생각이 바뀐 건 그 hook에 우회 명령을 넣어 본 뒤다. 여덟 개를 넣었는데 하나도 못 막았다. 그때 내가 쓰는 장치들이 실제로 무엇을 보고 판단하는지 모른다는 걸 알았다.
이 글에서 판단하려는 건 하나다. 에이전트를 쓸 때 무엇을 기본으로 켜 두고, 무엇을 따로 점검해야 하는가. 그러려면 장치마다 안에서 어떻게 동작하는지 알아야 했다. 그래서 Claude Code 공식 문서, 공개된 sandbox 소스, Anthropic이 공개한 보안 권고 35건을 차례로 읽었다.
§1공격이 들어온 길
먼저 최근 사례가 어디로 들어왔는지만 짧게 정리한다.
- 에이전트가 읽는 텍스트. 2026년 4월에 공개된 "Comment and Control" 연구에서는 PR 제목에 숨긴 지시를 CI의 리뷰 에이전트가 따라, API 키를 PR 댓글로 올렸다.
- MCP 도구. Anthropic이 배포하는 Git MCP 서버에서 취약점 세 개가 나왔고, Filesystem MCP와 같이 켜면 코드 실행까지 이어졌다(2025년 12월 수정).
- plugin·skill 공급망. 한 skill marketplace 전수 조사에서 2,857개 중 341개가 악성이었고, 코딩 에이전트 CLI인 Cline은 탈취된 npm token으로 무단 배포된 버전이 약 8시간 풀려 있었다.
- 저장소에 커밋된 설정. 2026년 4월 npm 패키지 탈취에서는 손댈 수 있는 저장소마다
.claude/settings.json에 SessionStart hook을 심었다.
이 길을 막는다고 믿는 장치가 세 개 있다. 권한 규칙, trust 창, sandbox다.
| 장치 | 하는 일 |
|---|---|
권한 규칙 (deny / ask / allow) | 에이전트가 도구를 쓰기 직전에 규칙표와 대조해서 막거나, 묻거나, 통과시킨다 |
| trust 창 | 처음 여는 폴더의 설정(hook, MCP 서버, 허용 규칙)을 켜도 될지 묻는다 |
| sandbox | 에이전트가 실행하는 shell 명령을 OS가 정한 울타리 안에서 돌린다. 기본값은 꺼져 있다 |
세 장치는 덮는 범위부터 다르다. 권한 규칙은 모든 도구 호출 앞에 선다. sandbox는 shell 명령과 그 자식 프로세스만 감싼다. 공식 문서도 그만큼만 적는다.
The sandbox wraps shell commands.
Read 같은 내장 도구, hook, MCP 서버는 sandbox 밖에서 사용자 권한으로 돈다. 이 구조를 머리에 두고 장치를 하나씩 열어 봤다.
§2deny 규칙은 문자열을 읽는다
내 hook이 뚫린 이유부터 확인하고 싶었다. 정규식 hook과 Claude Code의 deny 규칙이 같은 방식으로 판단하는지가 궁금했다.
공식 문서의 Permissions 페이지에 매칭 방식이 꽤 자세히 나와 있다. 명령 문자열을 받으면 &&, ||, ;, |, 개행 단위로 subcommand를 나눈다. timeout, nohup, nice 같은 wrapper는 떼어 내고, subcommand마다 규칙과 따로 맞춰 본다. 평가 순서는 deny, ask, allow이고 처음 매칭된 규칙이 결과를 정한다.
Read deny가 shell 명령에 적용되는 범위는 더 좁다. cat, head, tail, sed, tee처럼 Claude Code가 아는 명령의 경로 인자와 리다이렉트 대상만 본다. 문서는 그 한계를 이렇게 적는다.
They don't apply to a command that reads files without naming them, such as
grep -r pattern .run from the directory that holds the file, or to arbitrary subprocesses that read or write files indirectly, like a Python or Node script that opens files itself.
그리고 같은 페이지에 이 문장이 있다.
a deny or ask rule covers the invocation Claude usually produces and isn't a security boundary around the program.
내 hook과 원리가 같았다. 둘 다 shell이 명령을 해석하기 전의 문자열을 보고 판단한다. 내 hook에 넣은 명령 18개의 결과도 그 원리대로 나왔다.
| 구분 | 예 | 기대대로 동작 |
|---|---|---|
| 직접 읽기 | cat .env, base64 .env | 6 / 6 |
| 우회 시도 | cat .e*v, 변수로 이름 조합, find ... -exec cat, 스크립트 경유 | 0 / 8 |
| 정상 명령 | ls, ./gradlew test | 4 / 4 |
명령은 실행하지 않았고 hook에 JSON 입력만 넘겨 판정을 받았다. 케이스는 내가 직접 만들었다. 파일 이름이 문자열에 보이면 막았고, 이름을 조합하거나 다른 프로그램을 한 번 거치면 하나도 못 막았다.
§3공개 권고 35건을 갈래로 나눠 봤다
문자열을 보고 판단하는 방식이 실제로 얼마나 뚫렸는지 궁금했다. Anthropic은 Claude Code 저장소에 보안 권고를 공개한다. 2026년 10월 15일에 GitHub API로 전체를 조회하니 35건이었다. 제목과 설명을 읽고 원인별로 나눴다.
| 갈래 | 건수 | 예 |
|---|---|---|
| 명령 검증 우회 | 8 | echo, rg, sed, find, cd, $IFS, zsh 문법 |
| 경로·symlink 해석 | 6 | symlink로 deny 우회, 쓰는 시점의 symlink 경쟁 |
| trust 판단 전 실행 | 8 | 저장소 설정으로 trust 창 생략, git worktree 경로 위조 |
| 네트워크 허용 목록 | 3 | 기본 허용 목록이 넓음, 미리 허용된 도메인 경유 |
| sandbox 탈출 | 2 | sandbox 안에서 settings.json에 hook을 써 둠 |
| plugin 공급망 | 1 | plugin commit SHA 고정 우회 |
| 기타 | 7 | IDE 확장, Windows, Desktop 등 CLI 밖 제품 |
명령 검증 8건은 모두 Claude Code가 읽은 명령과 shell이 실제로 실행한 명령이 달랐던 경우였다. 경로·symlink 6건도 구조가 비슷하다. 문자열로 본 경로와 파일 시스템이 실제로 가리키는 곳이 달랐다. 예를 들어 1.0.93에서 고친 권고는 $IFS와 짧은 flag를 파싱하는 오류 때문에 읽기 전용 검사를 넘어갈 수 있었다는 내용이다. sed나 find도 같은 구조다. 내 정규식 hook이 glob 하나에 뚫린 것과 원리가 다르지 않다.
여기서 deny에 대한 기대를 정리했다. deny는 에이전트가 평소에 쓰는 형태의 명령을 걸러 주는 실수 방지 장치다. 경계는 다른 곳에 그어야 한다.
§4trust 창은 넓히는 설정만 붙잡는다
두 번째 장치는 trust 창이다. 저장소를 처음 열면 "이 폴더의 파일을 믿겠느냐"고 묻는 창이다. 이 창이 정확히 무엇을 붙잡아 두는지 문서로 확인했다.
수락하기 전까지 보류되는 건 권한을 넓히는 설정이다. 저장소 설정의 permissions.allow와 additionalDirectories, 그리고 hook이다. 대화형 세션에서는 내 ~/.claude/settings.json에 든 hook까지 포함해 모든 settings 파일의 hook을 보류한다. deny와 ask는 제한만 하므로 수락과 상관없이 바로 적용된다.
예외가 하나 있었다. claude -p나 SDK로 실행하면 이 창이 뜨지 않는다.
-por SDK session: Claude Code never shows the dialog and treats the folder as trusted, so hooks committed in a repository's.claude/settings.jsonrun in a folder you've never trusted
CI에서 에이전트를 돌리는 구성이면 이 문장이 그대로 해당된다. MR 브랜치에 커밋된 hook이 trust 단계 없이 실행된다. 문서가 권하는 대응은 실행마다 --settings '{"disableAllHooks": true}'를 넘기는 것이다. 사용자 설정에 넣는 것만으로는 부족하다. 저장소 설정에 false가 있으면 그쪽이 덮는다.
권고 35건 중 8건이 trust 쪽이었다. 가장 이해하기 쉬운 건 2.1.53에서 고친 권고다.
Claude Code resolved the permission mode from settings files, including the repo-controlled
.claude/settings.json, before determining whether to display the workspace trust confirmation dialog.
trust 창을 띄울지 정하려고 설정을 읽었는데, 그 설정이 아직 믿지 않은 저장소에서 왔다. git worktree 경로 위조, yarn plugin 자동 로드도 결국 같은 순서 문제였다. 8건 모두 권고 원문에 "자동 업데이트 사용자는 이미 받았다"는 문장이 들어 있다.
§5sandbox 안을 열어 봤다
세 번째는 sandbox다. Claude Code는 sandbox를 오픈소스 패키지 @anthropic-ai/sandbox-runtime으로 구현한다. 0.0.79 소스를 받아 읽었다.
macOS에서는 명령마다 Seatbelt profile을 문자열로 만들어 sandbox-exec -p로 실행한다. 소스의 규칙 순서를 줄여 옮기면 이렇다.
(version 1)
(deny default) ; 전부 막고 시작
(allow process-exec)
(allow file-read*) ; 바로 다음에 읽기는 전부 연다
(deny file-read* <denyRead 경로>)
(allow file-write* <작업 폴더>)
(allow network-outbound (remote ip "localhost:<proxy>"))deny default로 시작하지만 다음 줄에서 파일 읽기를 전부 연다. 좁히는 건 쓰기와 네트워크다. Linux에서는 bubblewrap으로 루트를 --ro-bind / /로 읽기 전용으로 붙이고 쓰기 허용 경로만 다시 연다. 네트워크는 --unshare-net으로 namespace를 통째로 뗀다. seccomp은 Unix socket 생성만 막는다.
네트워크는 두 OS 모두 sandbox 밖에서 도는 HTTP·SOCKS proxy를 거친다. 이 proxy는 호스트 이름을 허용 목록과 대조할 뿐, 무엇을 보내는지는 보지 않는다.
이 구조를 알고 나니 공식 문서의 기본값 표가 다르게 읽혔다. sandbox를 켜도 기본값으로는 열려 있는 것이 다섯 가지 있었다.
- 읽기: "Most of the machine, including credential files such as
~/.sshand~/.aws/credentials". 기본 deny 목록은 없고, 막을 경로는credentials나denyRead에 직접 적어야 한다. - 환경변수: "Inherited from Claude Code, including any secrets in its environment".
credentials.envVars로 지우거나 가려야 한다. - sandbox 밖 프로세스: "A
denyReadentry doesn't stop the Read tool". hook과 MCP 서버도 밖이다. - 못 켜졌을 때: 의존성이 없거나 플랫폼을 지원하지 않으면 기본값은 sandbox 없이 명령을 돌리는 것이다.
failIfUnavailable: true여야 시작 단계에서 멈춘다. - 권한 우회 모드: 허용 목록 밖 호스트가 "Allowed without a prompt"다.
strictAllowlist를 켜지 않으면 네트워크 허용 목록이 사실상 풀린다.
구멍은 넓힌 설정에서 생긴다
sandbox 문서와 sandbox-runtime 문서에는 사용자가 넓힌 설정이 어떤 길을 여는지도 적혀 있다.
| 넓힌 설정 | 생기는 길 |
|---|---|
허용 도메인에 github.com | "allowing github.com lets a process push to any repository" |
| docker socket 같은 Unix socket 허용 | 호스트 권한을 사실상 넘겨준다 |
excludedCommands에 docker compose * | "Claude can write that file and then run it outside the sandbox" |
| sandbox 밖 재시도 (기본값) | 막힌 명령을 밖에서 다시 돌리자고 묻는다. 우회 모드에서는 묻지 않는다 |
예외를 추가하고 싶어지는 이유는 평범하다. 문서는 docker가 sandbox와 호환되지 않는다고 적는다. JVM은 proxy 환경변수를 읽지 않아서 sandbox-runtime이 JAVA_TOOL_OPTIONS로 agent를 끼워 넣는다. gh 같은 Go CLI는 macOS에서 TLS 검증에 실패하는 경우가 문서에 적혀 있다. 백엔드 개발 환경이면 이 셋 중 하나는 만난다. 빌드가 막힐 때마다 예외를 하나씩 넣으면 그만큼 울타리 밖으로 나가는 명령이 늘어난다.
§6MCP 서버의 자기 신고
MCP 도구에는 readOnlyHint, destructiveHint 같은 annotations라는 꼬리표가 붙는다. 처음에는 이걸로 쓰기 도구를 구분하면 되겠다고 생각했다. MCP 스펙은 이렇게 적는다.
clients MUST consider tool annotations to be untrusted unless they come from trusted servers.
서버가 스스로 붙인 꼬리표라서다. 그래서 Claude Code는 도구 이름(mcp__<서버>__<도구>)으로 권한을 걸게 한다. allow는 서버 이름을 고정한 mcp__jira__* 형태만 받고, mcp__* allow는 경고와 함께 무시한다. deny와 ask는 mcp__*delete*처럼 넓게 걸 수 있다.
도구 설명(description)도 같은 문제를 안고 있다. 세션을 시작할 때는 도구 이름과 server instructions만 모델에 들어가고, 도구를 불러올 때 description 전체가 들어간다. 그 안에 지시를 심으면 모델은 그걸 지시로 읽는다. MCPTox benchmark는 실제 MCP 서버 45개, 에이전트 설정 20개로 이걸 측정했고 평균 공격 성공률이 36.5%였다.
§7어디서 막을지 다시 고르기
장치 넷을 들어오는 길과 겹쳐 보면 이렇다.
| 들어오는 길 | 권한 규칙 | trust 창 | sandbox | container·VM |
|---|---|---|---|---|
| 읽은 텍스트가 shell 명령으로 | 일부 (문자열 매칭) | 못 막음 | 일부 (읽기는 열림) | 일부 (네트워크 제한 시) |
| MCP 쓰기·삭제 도구 남용 | 막음 (이름으로 ask) | 일부 (저장소 .mcp.json만) | 못 막음 | 일부 (token 범위 안) |
| plugin·skill의 hook | 못 막음 | 못 막음 | 못 막음 | 막음 |
| 저장소에 커밋된 hook | 못 막음 | 일부 (-p는 없음) | 못 막음 | 막음 |
| secret 파일 읽기 | 일부 (Read·cat만) | 못 막음 | 설정 시 막음 | 막음 (넣지 않으면) |
비교 기준은 막는 범위, 도입 비용, 개발 불편 순으로 뒀다. 권한 규칙과 sandbox는 비용이 낮고 막는 범위가 서로 겹치지 않아서 둘 다 켠다. hook과 MCP는 어느 장치도 제대로 감싸지 않으니 설치하는 시점에 사람이 본다. container는 가장 넓게 막지만 개발 환경을 통째로 옮겨야 해서, 사람이 확인 창을 보지 않는 실행에만 쓴다.
에이전트의 파일·도구 접근을 어디서 막을까
§8적용 기준
설정은 앞에서 본 동작에서 그대로 나왔다.
{
"permissions": {
"deny": [
"Read(**/.env)", "Read(**/.env.*)", "Read(**/.npmrc)",
"Read(**/application*.yml)", "Read(**/*.pem)", "Read(**/*.key)"
],
"ask": [
"mcp__*create*", "mcp__*update*", "mcp__*delete*",
"mcp__*add*", "mcp__*edit*", "mcp__*transition*"
]
},
"sandbox": {
"enabled": true,
"failIfUnavailable": true,
"allowUnsandboxedCommands": false,
"credentials": {
"files": [
{ "path": "~/.ssh", "mode": "deny" },
{ "path": "~/.npmrc", "mode": "deny" }
],
"envVars": [
{ "name": "GITHUB_TOKEN", "mode": "deny" },
{ "name": "NPM_TOKEN", "mode": "deny" }
]
}
}
}- deny는 문자열 매칭이라 실수 방지로 두고, secret 파일은 sandbox
credentials로 한 번 더 막는다. sandbox를 켜면 Read deny 경로도 OS 수준 읽기 금지에 합쳐진다. - sandbox가 못 켜지면 멈추게 하고(
failIfUnavailable), 밖에서 재시도하지 않게 한다(allowUnsandboxedCommands: false). - 권한 우회 모드는 container 안에서만 쓴다. 우회 모드에서는 네트워크 허용 목록과 재시도 확인이 같이 사라진다.
- 자동 업데이트를 끄지 않는다. trust 계열 수정은 모두 자동 업데이트로 배포됐다.
- CI에서
claude -p를 쓰게 되면 실행마다--settings '{"disableAllHooks": true}'를 넘기고, MR에서.claude/가 바뀌면 리뷰 대상으로 본다. - 빌드가 막히면
excludedCommands보다filesystem.allowWrite부터 검토하고, 예외 추가는 팀 리뷰를 거친다. - 외부 저장소를 clone하면 첫 실행 전에
.claude/와.mcp.json을 열어 본다. hook과 MCP 서버는 어느 장치도 감싸지 않는다.
한계도 적어 둔다. Claude Code 본체 소스는 공개돼 있지 않아서 내부 호출 경로는 공식 문서로만 확인했다. 권고 35건의 갈래는 제목과 설명을 보고 내가 나눈 것이다. sandbox를 켠 상태로 내 Gradle 빌드를 돌려 보는 건 아직 하지 않았다.
정규식 hook은 계속 쓴다. 실수로 설정 파일을 읽는 건 막아 주고, 막을 때 이유를 모델에게 돌려줘서 다른 방법을 찾게 한다. 다만 이제는 보안 경계로 세지 않는다. 문자열을 보고 판단하는 장치는 shell이 실제로 해석한 결과와 어긋나는 순간 뚫린다는 걸 명령 검증 권고 8건과 내 hook이 같이 보여 줬다. 다음 할 일은 sandbox를 켜고 빌드를 돌려, 어떤 예외가 정말 필요한지 목록으로 남기는 것이다.