2026년 10월 7일 심야, Claude Code에게 개발을 맡긴 사이에 Mac의 홈 디렉토리 내 파일들이 한꺼번에 사라졌습니다.
Claude Code가 명령을 실행하기 전에 수행하는 판단이나 직접 만든 hook도, 보고 있는 것은 Claude가 입력한 명령어 단 한 줄입니다. 그 명령어가 실행하는 스크립기 안에서 rm -rf에 전달되는 경로는 볼 수 없습니다. 그래서 저는 OS에서 쓰기 대상을 좁히는 사일로(sandbox)와 백업을 주된 대책으로 하고 있습니다.
요약
-
원인(추정): 스크립트에 '인수가 홈 디렉토리라면 중지'하는 가드를 추가하는 작업에서, 서브 에이전트가 가드를 작성하기 전에 테스트를 실행했고, 그 테스트가 실제 홈 디렉토리를 전달했습니다.
-
피해: push하지 않았던 약 9일 분의 커밋들이 로컬의 .git과 함께 사라졌습니다. 대화 로그에서 1,004개의 파일을 복구할 수 있었지만, 서브 에이전트가 작성한 부분은 로그에 남아있지 않아 복구할 수 없었습니다.
-
주요 대책: OS로 쓰기 대상을 좁히는 사일로와 Mac 외부 백업. deny 규칙과 hook은 보조적입니다.
-
먼저 할 세 가지(확인 방법은 '자신의 설정에서 확인하는 것'):
~/.claude/settings.json에서 sandbox.enabled: true와 allowUnsandboxedCommands: false로 설정합니다. 쓰기 대상을 OS가 좁히고, 거부된 명령어를 Claude가 사일로 없이 재실행하지 못하게 합니다. 내부에서 작동하지 않는 명령어는 excludedCommands에 이름을 올리고 제외시킵니다.
- Time Machine 등 Mac 외부 백업을 하고, OK를 받은 커밋은 즉시 push합니다. 사일로 안에서도 작업 디렉토리 내부는 삭제할 수 있기 때문입니다.
- 삭제 처리의 테스트는 테스트별 임시 디렉토리 내에서만 실행하도록 합니다. 실제 경로를 전달하면 실제 것이 사라지기 때문입니다.
-
사일로 밖에서 실행하는 것: Xcode의 테스트와 빌드, xcrun simctl, gh, git push.
(외부로 빼는 이유와 대체 대책을 결정하여 제한하고 있습니다.) - 설정・hook・테스트 68건: https://github.com/ryodevapp/claude-code-guards
2026년 10월 19일 기준 (macOS, Claude Code는 CLI 2.1.292와 VS Code 확장 2.1.301)
무슨 일이 일어났나
iOS 앱 개발을 auto 모드로 밤새 진행했습니다. auto 모드는 작업별 허가 확인을 사람 대신 분류기(다른 모델)에 맡기는 모드입니다.
당시 상황은 다음과 같았습니다.
- 사일로도 Time Machine도 적용하지 않았습니다.
- 서브 에이전트에게 지시할 때마다 'push 하지 않는다'고 넣었습니다.
sign_macos27.sh는 숏컷 App의 파일 서명 및 검증에 사용하는 셸 스크립트로, 인수로 받은 디렉토리를 rm -rf하는 처리를 가지고 있습니다. 표의 RED는 TDD로 구현 전에 테스트를 실행하여 실패를 확인하는 단계입니다.
| 시간(JST) | 발생한 일 |
|---|
| 01:58 | 리뷰용 서브 에이전트가 sign_macos27.sh에 인수가 홈 디렉토리라면 중지하는 가드 추가를 제안했습니다. 메인 세션은 작업용 서브 에이전트에게 요청했습니다. |
| ... | |
아침에 보고서를 읽고 나도 모르게
이전 프레임에서 판별된 범위, 아래쪽 프레임에서 보이지 않는 범위에 걸쳐 점선은 당시 존재하지 않았던 레이어입니다. 메인 세션이 요청한 것은 가드(guard) 추가였고, 서브 에이전트의 마지막 로그는 '가드와 그 테스트를 추가하고 RED로 실행한다'였습니다. 이 테스트는 sign_macos27.sh에 홈 디렉터리를 전달했을 때 멈추는지 확인하는 것이었다고 보고 있습니다. RED 단계에서는 가드가 아직 작성되지 않았었기 때문에, sign_macos27.sh은 전달받은 실제 홈 디렉터리에서 rm -rf를 실행했다고 보고 있습니다. 실행 전 판별로 보인 것은 '테스트를 실행한다'라는 한 줄뿐이었고, 멈출 이유가 없었을 것입니다.
공식 문서에서도 Bash 규칙이 보는 것은 Claude가 작성한 한 줄이라고 적혀 있습니다.
A Bash rule matches the command text Claude writes, after Claude Code splits compound commands and strips wrappers. It doesn't match the same program invoked in a different form, so a deny or ask rule covers the invocation Claude usually produces and isn't a security boundary around the program.
Configure permissions — What a Bash rule doesn't match
Bash의 규칙은 Claude가 작성한 문자열에 일치시키는 것이며, 같은 프로그램을 다른 형태로 호출하면 일치하지 않습니다. 따라서 deny나 ask 규칙은 그 프로그램을 둘러싼 보안 경계가 아니라는 의미입니다.
명령을 실행하기 전 판별(pre-execution judgment)은 다음 세 가지입니다. 이 글에서는 세 가지를 묶어 '실행 전 판별'이라고 부릅니다.
| 이름 | 하는 일 | 이번에 멈추지 않은 이유 |
|---|
| deny 규칙 | 정해진 패턴과 일치하는 명령어 또는 도구 호출을 거부함 | 테스트를 실행하는 한 줄은 어떤 패턴에도 일치하지 않음 |
| critical path 보호 | 파일 시스템의 루트・홈 디렉터리・작업 디렉터리 등을 대상으로 하는 rm ・rmdir를 allow 규칙이나 hook으로는 자동으로 허용하지 않음[1] | 대상은 Claude가 치는 rm ・rmdir. 테스트에서 호출된 스크립트 파일 안의 rm이 대상이 될 것이라고 공식 문서에는 쓰여 있지 않음 |
| auto 분류기 | 세션 이전에 존재하는 파일을 되돌릴 수 없는 형태로 지우는 작업을 기본적으로 멈춤 | 분류기에 전달되는 것은 사용자 메시지・도구 호출・CLAUDE.md이며, 도구의 결과(Read로 읽은 스크립트 내용 등)는 전달되지 않음[2] |
Claude가 rm -rf ~를 직접 입력했다면, critical path 보호에 의해 거부되었을 것입니다. 나중에 추가한 자체 제작 hook도 받는 것은 같은 한 줄입니다.
critical path 보호의 세부 동작
- critical path를 대상으로 하는
rm ・rmdir는 auto 모드에서는 터미널일 경우 확인 메시지가 뜨고 2분 이내에 답하지 않으면 거부됩니다. 터미널이 아닌 경우에는 확인 없이 거부됩니다(v2.1.281 이후). 당일 버전은 2.1.286입니다. 기록을 보면, 작업은 iPhone・데스크톱 앱・웹에서 전송되었습니다. Claude Code의 화면이 터미널이었는지는 기록으로 알 수 없지만, 어느 쪽이든 allow 규칙이나 hook으로는 통과할 수 없습니다.
- 공식 문서에서 내용까지 분석한다고 적혀 있는 것은 서브쉘이나
bash -c 같은 인라인 스크립트까지입니다. 스크립트 파일에는 해당되지 않습니다.
대책의 개요
샌드박스(sandbox)가 없으면, 허용된 명령어는 제 사용자 계정이 쓸 수 있는 장소라면 어디든 쓸 수 있습니다. 샌드박스는 동작하는 프로세스에 OS가 강제하기 때문에, 모델이 무엇을 실행하든, 허용된 명령어가 이름 이상을 하든 효과가 있습니다[3]. 따라서 샌드박스와 백업을 주요 대책으로 삼고, deny 규칙과 hook은 자주 발생하는 위험한 패턴을 미리 막는 보조 수단으로 사용하고 있습니다.
| 層 | 防げないもの |
|---|
| サンドボックス(주). 쉘의 커맨드와 자식 프로세스의 쓰기 위치・통신 위치를 OS에서 제한 | 작업 디렉토리 내 삭제(.git 및 push하지 않은 커밋 포함). 부모 디렉토리에서 실행했을 때, 그 아래 리포지토리. 샌드박스 외부에서 작동하는 것(Read・Edit・Write 등의 도구, MCP 서버, hook, excludedCommands 설정으로 샌드박스 밖에 내보낸 커맨드) |
| ... | /bin/rm … ・bash -c '…'와 같은 다른 작성 방식 |
| hook(보조). 도구 실행 전에, 삭제 위치・쓰기 위치를 판별함 | 스크립트 안에서 조합하는 경로. 작성 방식을 바꾼 삭제. hook의 타임아웃이나 시작 실패는 기본적으로 그대로 실행되므로, ` |
| 키 | 의미 |
|---|
allowUnsandboxedCommands: false | |
샌드박스에 거부된(rejected) 명령을, 샌드박스 없이 재실행하지 못하게 함. 관리자 설정의 allowUnsandboxedCommands: false를 적용함.<br>이 경우, 리포지토리의 .claude/settings.json 및 .claude/settings.local.json에 작성된 excludedCommands나 allowWrite와 같은 완화 설정을 무시하게 됩니다. 사용자 설정만으로는 이렇게 되지 않습니다[5]<br><br>failIfUnavailable: true | |
샌드박스를 사용할 수 없는 환경에서는, 시작 시점에 중단함.<br><br>excludedCommands | |
샌드박스 외부에서 실행되는 명령. 호출(call) 한 번 안에 있는 모든 명령이 일치할 때만 샌드박스 외부에서 실행됩니다.<br><br>denyWrite의 ~/Projects/*/… | |
부모 디렉토리인 ~/Projects에서 시작했을 때, 그 아래 리포지토리의 .git/hooks, .git/config, .claude 파일을 쓰기 못하게 합니다.<br><br>credentials | |
샌드박스 내부 명령으로부터 인증 정보를 읽는 것을 막습니다. excludedCommands에 포함된 명령에는 적용되지 않습니다.<br><br>Linux 및 WSL2에서는, allowWrite<br>denyWrite<br>(샌드박스에 병합되는 Edit(...) 규칙도) 중 *, ?, [를 포함하는 것은 작동하지 않습니다. 제가 확인한 것은 macOS에서만 그렇습니다.<br><br> | |
deny 규칙
{
"permissions": {
"deny": [
...
- 파일을 쓰기 못하게 하는 도구의 규칙은<br>
Edit(...)로 작성합니다. Edit, Write 등 파일을 편집하는 모든 내장 도구에 적용됩니다. Read(...)<br>및 Edit(...)의 deny는 샌드박스 설정에도 병합되므로, 샌드박스 내부의 Bash에도 적용됩니다(excludedCommands에 포함된 명령에는 적용되지 않음) - hook 파일과<br>settings.json에 대한 Edit을 거부하여 Claude가 hook 파일을 수정하지 못하게 합니다. 샌드박스 외부에서 작동하는 Xcode의 MCP는 서버 이름으로 모든 도구를 거부합니다(이전에는 한 번도 호출된 적 없음) - 샌드박스 외부에서 실행되는<br>gh<br>및 git push로부터 GitHub 상의 리포지토리나 데이터를 삭제/변경하는 주요 작업을 막습니다. gh * delete*와 같이 광범위하게 작성하면 gh pr create 본문에도 일치하므로, 서브 커맨드를 나열합니다.<br><br>
hook 판별
{
"hooks": {
"PreToolUse": [
...
삭제용 hook (Bash・Monitor)<br>- 삭제해도 되는 것은 명령을 실행하는 디렉토리를 포함하는 git 리포지토리 내부(루트 자체와 .git 제외) 및 임시 디렉토리 내부뿐입니다.<br>bash -c<br>및 eval<br>및 heredoc, | bash<br>및 python -c 등은 내용까지 읽습니다. perl -e<br>및 node -e<br>및 ruby -e<br>및 awk<br>및 swift -e 등은 내용을 분석하지 않고, rmtree<br>rmSync<br>unlink<br>system과 같은 삭제/실행 단어가 있으면 중단합니다.<br>os.path.join(os.sep, …)나 문자열 연결처럼, 삭제 대상 경로가 그대로의 문자열로 작성되지 않은 삭제는 막습니다. 알 수 없는 변수나 $(...)와 같이 판별할 수 없는 것도 막습니다. 심볼릭 링크는 링크의 끝에서 판별합니다 - 샌드박스 외부에서 실행되는<br>git push<br>및 xcodebuild<br>Tools/test.sh 등은, ~/Projects
직하(直下) 리포지토리 내에서만 통과시키기 - hook 자체가 판정 도중에 실패했을 때도 종료 코드 2로 중단시킵니다 (공식적으로는, 종료 코드 1은 차단하지 않는 것으로 간주).
- hook이 타임아웃이나 실행 실패로 끝났을 때는
"onFailure": "block"으로 중단시킵니다. CLI에서 적용되는 것은 2.1.295부터이며, Homebrew의 2.1.292에서는 알 수 없는 키로 무시됩니다.
쓰기용 hook(Edit・Write)
- 쓸 수 있는 곳은
~/Projects 아래의 git 리포지토리 내부와 임시 디렉토리 내부입니다. 그 외에 Claude의 메모 및 계획 파일 등 정해진 몇몇 장소 - 그중에서도 .git과 샌드박스 외부에서 실행되는 처리용 설정 파일(.claude/,
・.mcp.json
・.githooks/ 등)는 차단합니다 - ".git이 있으면 허용" 같은 방식은 하지 않습니다. /opt/homebrew나 ~/.oh-my-zsh도 git 리포지토리이며, 거기에 쓰면 PATH를 가로챌 수 있기 때문입니다.
리뷰 전용 서브 에이전트에게 hook을 4번 재검토하게 했고, 매번 우회하는 부분이 발견되었습니다. 모두 수정하여 테스트에 추가했습니다. 다음 3개는 hook을 직접 만드는 사람이 자신의 테스트에서 확인할 수 있도록, 발견된 우회 사례 중 선택한 예시이며, 현재 모두 차단됩니다.
"r"m" -rf ~/x처럼 명령어 이름을 따옴표로 분리하는 방식 -
$C -rf ~처럼 명령어 이름을 변수에 넣는 방식 - 임시 디렉토리에 만든 리포지토리로 git push를 하여, push 대상의 hook을 샌드박스 외부에서 실행시키는 방식
판정할 수 없을 때는 차단하는 쪽으로 하고 있기 때문에 오탐지도 있습니다. 기사를 수정하는 Python 스크립트 안의 rm -rf ~ 같은 문자열이나, grep 검색어의 백틱(backtick)을 이용해 아무것도 지우지 않는 명령어를 차단한 적이 있으며, 발견된 것은 테스트에 추가하여 수정했습니다.
리디렉션(cp), sed -i, truncate를 통한 덮어쓰기는 평소 편집 자체이므로 hook에서는 막지 않습니다. 리포지토리 외부로의 쓰기는 샌드박스가 차단하고, 리포지토리 내부는 커밋과 push로 되돌립니다.
자세한 판정 목록은 README에 있습니다.
테스트 예시
판정 함수에 가짜 홈 디렉토리(/fake/home)를 전달하여 확인합니다. 임시 디렉토리 외부에서는 삭제 처리를 단 하나도 실행하지 않습니다. 테스트는 2개의 hook을 합쳐 총 68건입니다. 발췌본에서는 ENV와 fake_repo_of의 정의를 생략했습니다.
HOME = "/fake/home"
PROJ = "/fake/home/proj"
TMP = "/fake/tmp"
...
샌드박스 외부에서 실행하는 것들
샌드박스 내부에서는 작동하지 않는 명령어와, 샌드박스 안에서 읽을 수 없게 만든 인증 정보를 사용하는 명령어는 excludedCommands에 넣어 외부에서 실행합니다. 외부에 내보내는 명령어는 표의 상단 4개뿐이며, 각각 대체 방지책을 마련했습니다. MCP 서버는 설정과 관계없이 샌드박스 외부에서 작동하므로, 표의 마지막에 배치했습니다.
| 외에서 동작하는 것 | 외에서 실행하는 이유 | 대체 방안 |
|---|
xcodebuild ・Tools/test.sh (xcodebuild로 테스트를 실행하는 자체 제작 스크립트) | 샌드박스 안에서는 가상 터미널을 열 수 없어 Xcode의 테스트가 하나도 돌아가지 않음 | Tools/test.sh는 denyWrite와 Edit의 deny로 덮어쓸 수 없음. hook이 ~/Projects 바로 아래 리포지토리 내에서만 통과하도록 함. 테스트나 빌드 내용은 제한하고, 삭제 처리 테스트는 가짜 경로 또는 임시 디렉터리에서만 실행함 |
xcrun simctl | 시뮬레이터 구동, 앱 교체, 화면 촬영에 사용 | hook이 원하는 프로그램을 동작시킬 수 있는 simctl spawn과 리포지토리 외부로의 쓰기를 막음. 앱 교체와 구동은 통과시킴 |
gh | 인증 정보 ~/.config/gh를 샌드박스 안에서 읽을 수 없게 함 | deny 규칙으로, 삭제/변경의 주요 서브 커맨드를 거부함 |
git push | push 인증이 gh를 통해 이루어지며, 이 역시 같은 ~/.config/gh를 사용함 | .githooks와 .git/hooks를 쓰기 불가하게 함. hook이 ~/Projects 바로 아래 리포지토리 내에서만 통과하도록 하고, 로컬 경로로의 push를 막음 |
| Xcode의 MCP 서버 (himekuri의 리포지토리만) | MCP 서버는 원래 샌드박스 외부에서 나의 권한 그대로 동작함 | deny 규칙으로 도구를 전부 거부하고 있음. 지금까지 한 번도 호출한 적이 없음 |
모두 샌드박스 외부에서의 호출이기 때문에, auto 분류기의 판정도 받습니다[6].
공식 문서에서는 excludedCommands에 무엇을 넣는지 주의하고 있습니다.
An excluded command runs with your full access. … If you write a pattern that covers an interpreter, a script inside your working directory, … Claude can write that file and then run it outside the sandbox.
샌드박스화된 Bash 도구 설정
외부로 나간 커맨드는 나와 같은 권한으로 동작합니다. 인터프리터나 작업 디렉터리 안의 스크립트를 외부로 내보내면, Claude가 그 파일을 수정하고 나서 외부에서 실행할 수 있다는 의미입니다. Xcode 테스트 코드는 Claude가 작성하므로, xcodebuild는 이 경우에 해당됩니다. 따라서 위의 대체 방안 외에도, 사라져도 복구할 수 있도록 push와 Time Machine을 계속 사용하고 있습니다.
샌드박스 외부로 내보내는 것을 줄이기
-
완료: 두 개의 hook에 `
-
아직 push하지 않은 커밋이 남아있지 않은가? 서브 에이전트에게 'push 하지 말라'고 지시했다면, 정말 필요한 것인가?
-
Mac 외부에 백업이 있는가. Time Machine이나 클라우드 모두 좋다.
~/.claude/settings.json에서 sandbox.enabled가 true인지 확인한다. Claude에게 touch ~/sandbox-probe를 실행하게 하여 Operation not permitted로 실패하는지 테스트해 본다. excludedCommands에 인터프리터나 작업 디렉토리 내의 스크립트를 넣지는 않았는지 점검한다. 삭제 처리 테스트가 실제 경로(real path)를 사용하고 있지는 않은지 확인해야 한다. pytest 같은 경우, tmp_path처럼 테스트별 임시 디렉토리를 사용하는 것이 좋다. 쉘에서 임시 디렉토리를 만들 때는, 생성에 실패하면 멈추는 방식으로 작성했는지 점검한다.
또한 .claude/settings.local.json에도 excludedCommands, allowWrite, additionalDirectories가 없는지 확인해야 한다. /sandbox 화면에서 선택한 모드는 이 파일에 저장되며, 해당 프로젝트에만 적용된다. 내 환경에서도 다른 리포지토리의 이 파일에 dart * 같은 내용이 들어있었던 적이 있다. 관리자 설정(admin-required)으로 하면 이러한 완화된 설정은 무시된다.
4번 방식은 예를 들면 다음과 같다: ${D:?}는 변수가 비어있으면 멈추게 하는 방식이다. 내 환경에서는 mktemp -d가 실패하여 D가 빈 상태로 남아 있었고, 그 결과 cd $D가 실행되어 홈 디렉토리로 이동하는 문제가 발생했다(이후의 쓰기 작업은 샌드박스에 의해 거부됨).
set -eu
D=$(mktemp -d)
cd "${D:?}"
요약
구멍은 Claude가 실행하는 명령어 한 줄이 아니라, 그 명령어가 수행하는 테스트나 스크립트 안에 있었습니다. 단일 라인만 보고 판단하기는 어렵기 때문에, 나는 OS 레벨에서 멈추게 하는 샌드박스와 백업에 주로 의존하고, 샌드박스 외부에서 작동하는 것을 최소화하고 있습니다.
여러분은 Claude Code가 의도치 않은 파괴적인 작업을 하지 않도록, 그리고 만약 해버렸을 경우를 대비하여 어떤 준비를 하고 계신가요?
관련 링크
토론