포스트모템 · 2026-07-13 사고

Codex가 4초 만에
내 홈을 지우기 시작했다

그래서 삭제 안전장치를 4계층으로 만들었고, 거기서 마지막 교훈을 얻었다 — 방어를 만드는 것과 배포하는 것은 다르다.

1. 그날의 4초

2026년 7월 13일. AI 코딩 에이전트에게 검증 명령을 돌리게 하던 중이었다. 에이전트는 테스트 격리를 위해 임시 HOME 변수를 쓰고 있었는데, 그 변수의 스코프를 잘못 처리했다. 임시 홈을 지운다는 게, 실제로 실행된 명령은 이것이었다.

실제로 실행된 명령
rm -rf /Users/cmore

내 진짜 홈 디렉터리였다. 약 4초 만에 중단시켰다. 4초면 짧아 보이지만, SSD에서 4초는 짧지 않다.

지워진 것

~/.config, ~/.zshrc 삭제. ~/.local의 mise/Node 런타임 대부분 소실. ~/Music, ~/Pictures는 비워짐.

살아남은 것

프로젝트 저장소(그날 작업 중이던 코드 포함), 그리고 며칠 전 설치 스크립트가 남긴 1회성 백업 ~/.zshrc.bak.1783571268.

없었던 것

로컬 Time Machine 데이터 스냅샷. OS 업데이트 스냅샷만 있었다.

피해 규모를 가른 변수는 정확히 두 개였다. 중단까지 걸린 4초, 그리고 스냅샷의 부재. 둘 다 내 설계가 아니라 운이었다.

그래서 다음 날부터 삭제 안전장치를 4계층으로 만들었다. 그리고 이 글의 마지막에 쓸, 가장 민망한 교훈을 하나 더 얻었다.

2. 위협 모델: 무엇을, 누구로부터 지키나

먼저 분명히 하자. 이건 "AI가 악의로 내 파일을 지웠다"는 이야기가 아니다. 근본 원인은 임시 변수 스코프 오처리라는, 사람도 셸 스크립트에서 흔하게 저지르는 결함 클래스다. 특정 제품의 악덕이 아니라, 자율 셸 실행 권한을 가진 에이전트 전반의 구조적 리스크다.

  • 행위자 — 셸을 자율 실행하는 코딩 에이전트(Codex, Claude Code 등). 자연어를 셸 명령으로 번역하는 과정에서 변수 스코프, 따옴표, 경로 프리픽스, 중첩 셸 같은 미세한 실수가 생긴다. 그리고 에이전트는 목표 달성을 위해 막힌 길의 우회로를 스스로 만들어낸다.
  • 자산 — 홈 디렉터리, dotfiles, 런타임, 사용자 데이터, 워크스페이스 밖의 모든 것.
  • 핵심 속성 — 재귀 삭제는 되돌릴 수 없다. 다른 실수는 고치면 되지만, rm -rf는 4초 만에 복구 불가능한 결과를 만든다.

여기서 순진한 대응은 이렇다: "에이전트 규칙 파일에 rm -rf 금지라고 써두면 되지 않나?" — 안 된다. 왜 안 되는지가 이 글의 본론이다.

3. 4계층 방어

사고 후 만든 방어는 lazy-starter-kit에 코드와 테스트로 들어 있다. 계층은 넷이다: 정책 → 강제 → 대안 → 회귀방지.

계층 1 — 정책: 규칙은 필요하지만 충분하지 않다

AGENTS.md에 명문화했다:

AGENTS.md
- Never run `rm -r`, `rm -rf`, `rm -R`, or `rm --recursive` directly.
- Never bypass the shell-safety hook, including through
  `/bin/rm`, `sudo`, `env`, `xargs`, or a nested shell.

규칙 파일은 에이전트의 컨텍스트에 들어가므로 대부분의 경우 지켜진다. 하지만 "대부분"이 문제다. 규칙은 확률을 낮출 뿐 보장하지 않는다. 내 사고 자체가 증거다 — 에이전트는 rm -rf를 "쓰려던" 게 아니라 임시 홈을 지우려다 스코프를 놓쳤다. 규칙 문서는 실수를 못 막는다.

계층 2 — 강제: fail-closed 훅

그래서 에이전트의 셸 실행 직전에 끼어드는 PreToolUse 훅을 만들었다 (scripts/ai/shell-command-guard.js). Bash 도구 이벤트의 command 문자열을 검사해서 재귀 rm이면 exit 2로 차단한다.

"문자열에 rm -rf 있으면 차단" 수준의 grep이면 되지 않냐고? 우회 벡터를 나열해 보면 생각이 달라진다.

전부 차단되는 우회 벡터들
/bin/rm -rf build            # 경로 프리픽스
sudo /usr/bin/rm -rf build   # sudo + 절대경로
env FOO=1 rm -rf build       # env 래핑
... | xargs -0 rm -rf        # xargs 간접 실행
sh -c 'rm -rf build'         # 중첩 셸
r''m -rf build               # 빈 쿼트 삽입
rm \
  -rf build                  # 라인 컨티뉴에이션
rm -fr build                 # 플래그 순서 변경

그래서 가드는 단순 매칭이 아니라 정규화 → 토큰화 → 플래그 감지 순서로 동작한다:

  • 정규화 — 라인 컨티뉴에이션(\ + 개행)을 공백으로 치환(토큰이 붙어버리지 않게 잇는다), 빈 쿼트('', "") 제거, rm 앞의 백슬래시 이스케이프 해제
  • 토큰화 — 구분자(공백, ;, &, |, 괄호, 따옴표·백틱) 경계에서 rm 토큰을 찾되, …/rm 같은 경로 프리픽스 포함
  • 감지 — 해당 명령 구간에서 --recursive 또는 결합 플래그 속 r/R(-rf, -fr, -Rf 전부) 발견 시 차단

가장 중요한 원칙 하나. 파싱할 수 없는 입력은 통과가 아니라 차단이다(fail-closed). 훅 입력이 JSON이 아니거나 command가 문자열이 아니면 exit 2다. 안전장치가 "모르겠으면 허용"으로 동작하는 순간 그건 안전장치가 아니다.

계층 3 — 대안: 금지만 하면 우회한다

여기가 대부분의 가드레일 설계가 놓치는 지점이다. 에이전트에게는 빌드 디렉터리 청소 같은 정당한 재귀 삭제 수요가 있다. 금지만 하면 에이전트는 목표를 달성하려고 우회로를 찾는다 — 파일을 하나씩 지우든, 파이썬으로 shutil.rmtree를 부르든.

그래서 안전한 경로를 함께 줬다. lazy-safe-rm은:

  • git rev-parse --show-prefix로 현재 Git 워크스페이스의 루트를 계산하고
  • 대상 경로를 절대화한 뒤
  • 유일한 재귀 삭제 프리미티브인 safe_rm_rf_under에 위임한다

safe_rm_rf_under(lib/common.sh)의 규칙:

  • 대상은 허용 루트의 strict descendant여야 한다. 루트 자체, /, 그리고 $HOME은 무조건 거부
  • 경로의 컴포넌트를 하나씩 걸어 내려가며 물리적으로 심링크가 아닌지 검사한다. 문자열 프리픽스가 일치해도 중간에 심링크가 있으면 거부 — 심링크 하나로 경계 밖 데이터가 지워지는 걸 막는다
  • 배치 전체를 사전검증한다. 대상 10개 중 1개라도 불합격이면 아무것도 지우지 않는다. 부분 삭제는 없다
  • .., ., 빈 컴포넌트, 상대경로 전부 거부

내 사고에 이걸 대입해 보면: rm -rf /Users/cmore의 대상은 $HOME 그 자체다. safe_rm_rf_under는 이를 "protected boundary"로 즉시 거부한다. 홈을 별칭 경로로 우회해 접근해도 물리 경로 비교로 잡는다. 다만 이 3계층의 $HOME 보호는 $HOME이 올바를 때만 작동한다. 이번 사고의 근본 원인이 바로 그 HOME 변수 오처리였음을 떠올리면, 진짜 최후 방어선은 $HOME 값과 무관하게 재귀 rm 토큰 자체를 막는 2계층 가드다.

계층 4 — 회귀방지: 우회 벡터를 CI에 못박기

위의 우회 벡터 목록은 머릿속 지식이 아니라 테스트 코드다. tests/ai-shell-guard.sh는 위 벡터를 포함해 11종 우회를 expect_block으로 고정하고(대문자 -R, 분리형 --recursive까지), 반대로 rm -f file.txt(재귀 아님)와 lazy-safe-rm buildexpect_allow로 오탐도 막는다. malformed 입력의 fail-closed, 설치기의 멱등성(두 번 설치해도 훅 1개), 기존 설정 보존, 심링크 거부까지 전부 테스트다.

tests/safe-recursive-delete.sh는 경계 케이스를 고정한다 — 허용 루트 자체, /, ../outside, 형제 디렉터리, 심링크와 그 하위는 거부되고, 공백·한글·-로 시작하는 경로는 정상 삭제된다. 그리고 재미있는 검사 하나: 리포 전체에서 재귀 rm 프리미티브가 lib/common.sh 한 곳에만 존재하는지를 확인한다. 안전한 삭제 경로가 둘이 되는 순간 하나는 반드시 썩는다.

4. 마지막 교훈: 만들었는데 안 깔려 있었다

사고 다음 날, 이 글을 준비하면서 내 머신의 상태를 점검했다. 결과:

  • ~/.claude/settings.json의 PreToolUse — 가드 없음
  • ~/.codex 훅 — 가드 없음
  • ~/.local/bin/lazy-safe-rm없음

4계층 방어를 설계하고, 구현하고, 테스트까지 전부 통과시킨 그 머신에, 정작 가드가 하나도 설치돼 있지 않았다. 원인은 허무하다 — 나는 가드를 리포에 만들고, 설치기를 짜고, 테스트까지 전부 통과시켰지만, 정작 내 실제 머신에서 install-shell-guard.js를 한 번도 돌리지 않았다. 리포에 코드가 있는 것과 이 머신의 ~/.codex·~/.claude 훅으로 등록되어 실제로 실행되는 것은 완전히 다른 일이었다.

게다가 아이러니가 하나 더 있다 — 가드와 래퍼의 설치 위치는 ~/.local/share/lazy-starter-kit~/.local/bin, 즉 이번 사고가 통째로 날려버린 바로 그 ~/.local 아래다. 다시 말해 앞으로 설치를 하더라도, 안전장치가 자기가 막아야 할 사고로 함께 지워질 수 있는 위치에 산다는 뜻이다. 그러니 '설치했다'는 한 번의 사실이 아니라, 복구 이후에도 여전히 깔려서 돌고 있는지를 계속 확인해야 한다.

이게 이 글의 진짜 결론이다. 정책, 훅, 래퍼, 테스트 — 4계층이 전부 완벽해도, 그 장치가 지금 이 머신에 실제로 깔려서 돌고 있는지 검증하는 0계층이 없으면 전부 0이다. 방어를 만드는 것(authoring)과 방어가 배포되어 동작하는 것(deployment)은 완전히 다른 문제다.

5. 오늘 할 수 있는 것

  • 에이전트 셸 훅을 fail-closed로 설치하라. 파싱 실패 = 차단. "모르면 허용"은 안전장치가 아니다.
  • 정규화 후에 감지하라. 라인 컨티뉴에이션, 빈 쿼트, 경로 프리픽스, 결합 플래그를 처리하지 못하는 필터는 전부 뚫린다.
  • 금지와 함께 안전한 대안을 배포하라. strict descendant + 심링크 물리 검사 + 배치 사전검증. 정당한 수요를 막으면 에이전트는 우회한다.
  • 우회 벡터를 테스트로 고정하라. 오늘 막은 우회는 내일 회귀한다.
  • 가드가 실제로 깔려 있는지 주기적으로 검증하라. 특히 안전장치가 사고로 함께 지워질 수 있는 경로에 있다면.
  • 오프머신 백업. 내 경우 로컬 스냅샷 부재가 복구 범위를 좁혔다. 다만 이건 내 케이스의 사실이지 일반 복구 가이드가 아니다 — 4초 만에 중단된 것도, 백업 파일이 살아남은 것도 상당 부분 운이었다. 운에 기대지 않으려면 예방(설계)과 복구(백업)를 둘 다 갖춰야 한다.
금지만 하면 우회한다. 안전한 경로를 함께 줘라.
그리고 그게 깔려 있는지 확인하라.

모든 코드는 lazy-starter-kitAGENTS.md, scripts/ai/shell-command-guard.js, scripts/ai/lazy-safe-rm, lib/common.sh, tests/ai-shell-guard.sh, tests/safe-recursive-delete.sh에 있다. Windows에는 POSIX 래퍼 대신 Remove-KitTree라는 별도 대응물이 있다 (tests/windows-safe-remove.ps1).