AI 파일 복사 과정에서 안전장치를 통과한 채로 공유 서버에 존재하지 않는 프로젝트 폴더를 생성한 사건
요약
AI가 공유 서버에 파일을 복사하는 과정에서 안전장치(guard)와 bash의 이스케이프 처리 문제로 인해, 존재하지 않는 프로젝트 폴더가 생성되는 보안 사고가 발생했습니다. 이는 AI 개발 환경과 시스템 규칙 설계 시 고려해야 할 중요한 취약점을 보여줍니다.
핵심 포인트
- AI 파일 복사 작업은 정해진 스크립트와 안전장치(guard)를 거쳐야 합니다.
- bash의 이스케이프 처리 과정에서 변수가 예상대로 전개되지 않는 문제가 발생했습니다.
- 안전장치(guard)가 경로를 부분 문자열로 인식하는 방식으로 인해 사고가 막히지 않았습니다.
Claude Code에게 완성된 파일을 공유 서버의 정해진 폴더로 복사하는 작업을 맡기고 있다. 공유 서버에는 규칙을 두고, 쓰기는 정해진 스크립트에 의존하게 했으며, 쓰기 전에 검사(이하 guard)를 통해 기계적으로 멈추도록 했다. 이는 AI가 공유 서버에 접근할 수 있도록 하기 위한 안전장치였다.
어느 날 아침, 그 안전장치를 통과하여, 공유 서버에 존재하지 않는 프로젝트 폴더가 만들어졌다. 두 개의 브랜치 파일이 한 곳에 섞여 들어갔다.
원인은 두 가지가 복합적으로 작용했기 때문이다. bash의 이스케이프 처리로 인해 변수가 전개되지 않은 것과, guard가 경로를 부분 문자열로 인식하고 있었던 것이다. 둘 중 어느 하나만 문제가 있었다면 사고는 일어나지 않았을 것이다.
검증 환경: Windows 11, Claude Code (데스크톱 앱)의 Bash 도구 (Git Bash), Python 3. 2026-10-05.
전제: 공유 서버 규칙
공유 서버는 다른 담당자와 공유하는 프로젝트 데이터가 놓이는 장소이다. 자신의 실수가 다른 사람의 작업을 망치고, 다른 사람이 파일을 열어두면 자신의 처리가 망가질 수 있다. 그래서 Claude에게 접근 권한을 주기 위해 규칙을 세웠다.
절대 하지 말 것 (예외 없음)
- 삭제하거나 이동하지 않는다. 네트워크 드라이브의 삭제는 휴지통을 거치지 않고 그 자리에서 사라진다. 되돌리려면 관리자의 백업에 의존할 수밖에 없다 -
- 자동으로 덮어쓰지 않는다. 같은 이름의 파일이 있다면, 실행 전에 사람에게 확인한다. 폴더 이름을 바꾸지 않는다
개인 작업 폴더(로컬)는 이 대상에서 제외되며, 삭제나 덮어쓰기도 가능하다. 공유 서버와 동일하게 취급하면 자신이 어질러 놓은 중간 파일조차 정리할 수 없게 되기 때문이다.
쓰기는 하나의 스크립트로 통일한다
문서의 규칙을 지키지 않는 경우가 있다. 그래서 공유 서버로의 복사는 정해진 스크립트만 사용하게 하고, 그 스크립트가 쓰기 전에 guard를 통과하도록 했다. guard는 다음 경우에 1개의 파일도 복사하지 않고 멈춘다.
| 멈추는 조건 | 막아내는 사고 |
|---|---|
| 드라이브가 보이지 않음 | 연결되지 않은 상태로 로컬에 같은 이름의 폴더를 만드는 것 |
| ... | |
| 복사 중에 실패하면 |
화면 밖으로 잘려 있었다.
첫 번째 부분: 백슬래시가 bash에 도달하기 전에 절반이 되어 있었다
"S:\Active\A1234\$b\Draft"
을 bash가 그대로 받았다면, \\는 \가 되고, 그 후에 $b가 전개되어 S:\Active\A1234\2\Draft가 된다. 실제로는 $b가 남았다.
같은 환경에서 이렇게 시도했다.
b=2; echo "A\\B\\$b\\C"; printf '%s\n' 'A\\B\\$b\\C'
A\B$b\C
A\B\$b\C
두 번째 줄은 단일 따옴표이므로, bash는 받은 문자열을 그대로 출력한다. Claude Code의 세션 기록에는 이 명령어가 A\\B\\$b\\C로 기록되어 있다. 그럼에도 불구하고 bash가 받은 것은 A\B\$b\C였다. AI가 작성한 \는 bash에 도달하는 시점에서
(백슬래시)로 절반이 되어 있었다.
절반이 된 후의 \$b는 bash에게 있어서 "$라는 문자"와 b이다. 그래서 전개되지 않고 $b가 남았다. 첫 번째 줄의 결과 A\B$b\C는 사고 경로인 A1234$b\Draft와 같은 형태가 되어 있다.
추측: 절반이 되는 현상이 어느 단계에서 일어나고 있는지(도구 호출 전달인지, Git Bash 실행인지)까지는 확인하지 못했다. 아는 것은 "기록상의 명령어"와 "bash가 받은 문자열"이 다르다는 것뿐이다.
두 번째 부분: guard가 경로를 부분 일치로 보고 있었다
A1234$b\Draft\me...
은, guard를 통과했다. guard의 판정은 다음과 같았다(최소화).
# 다른 프로젝트에 쓰기 금지
if order_no not in dst:
g.append(
수정 전 구현에 새로운 테스트를 적용하자 15건이 실패했습니다. 수정 후에는 실제 공유 서버에 대한 드라이런(dry-run)에서 사고 경로, 가지치기 경로, 존재하지 않는 가지가 차단되고 올바른 복사 목적지만 통과했습니다. 또한 공유 서버상에 아무것도 생성되지 않았음을 확인했습니다.
## 스크립트 외의 경로를 훅(hook)으로 막기
guard를 수정해도 애초에 guard를 거치지 않는 쓰기가 남아 있었습니다. 사고 후에 Claude의 작업 절차를 검토해보니, 일부 건에서는 매뉴얼대로 `Copy-Item`을 사용해 공유 서버에 직접 작성하고 있었습니다. 매뉴얼 문구에 '스크립트를 통과할 것'이라고 적혀 있어도, 잊어버리면 그대로 지나가게 됩니다.
그래서 Claude Code의 **PreToolUse 훅**에서 공유 서버를 가리키는 쓰기를 외형적으로 막기로 했습니다. Bash/PowerShell 명령어 문자열과 Write/Edit의 쓰기 목적지를 보고, 공유 서버 경로(드라이브 문자・UNC・`/x/` 형식)와 쓰기 관련 명령어(`cp`, `Copy-Item`, `mkdir`, `rm`, `>`)가 같은 문맥에 나타나면 종료 코드 2로 중단시킵니다. 중단할 때는 대신 사용해야 할 복사용 스크립트의 이름을 이유로 제시합니다.
"PreToolUse": [{
"matcher": "Bash|PowerShell|Write|Edit|NotebookEdit",
"hooks": [{ "type": "command", "command": "python shared_drive_write_guard.py" }]
...
이 훅은 바로 활성화하지 않았습니다. 과거 세션 기록에서 추출한 941건의 도구 호출을 훅에 재생했습니다.
| 단계 | 차단 건수 |
|---|---|
| 초안 | 190건 |
| 조정 후 | 29건 |
남은 29건은 하나씩 확인한 결과, 모두 실제 직접 쓰기(공유 서버에 대한 `Copy-Item` / `New-Item`, 작업 영역에서의 `Remove-Item` / `Rename-Item` 등)였습니다.
막는 과정에서 테스트에 남긴 '통과해야 하는 방식'에는 다음과 같은 것들이 있습니다.
- 공유 서버 **에서** 로컬로의 `cp` (읽기만 함)
- `ffmpeg -i a.mp4 -c:a copy out.mp4`의 `copy` (명령어는 아님)
- `unzip -o`의 `-o` (덮어쓰기 옵션이며, 펼치기 목적지는 아님)
- heredoc 내 Python의 `>` (리다이렉트가 아님)
- 매뉴얼 본문에 공유 서버 경로가 적혀 있을 뿐인 파일 편집
복사 관련 명령어는 복사 목적지만 보고, 명령어는 명령어가 위치할 때만 포착하는 식으로 막아나갔습니다.
스스로 생각한 테스트 케이스만으로는 자신이 상상한 방식밖에 시험해 볼 수 없습니다. 과거 로그에는 실제로 AI가 작성한 명령이 남아 있습니다. 오탐(誤検知)을 줄이려면 이것이 가장 확실한 기준이었습니다.
## 그래도 막지 못하는 경로는 사람의 운영으로 닫기
이 훅에는 한계가 있습니다.
- 경로를 인수로 받는 **스크립트 파일 실행**은 막을 수 없습니다. 내부에서 무엇을 하는지는 명령어 문자열만으로는 알 수 없습니다 - Illustrator / InDesign의 JSX를 이용한 쓰기도 보이지 않습니다
- 변수나 인코딩으로 인해 경로가 문자열로 나타나지 않는 방식도 보이지 않습니다
따라서 이 훅은 '실수'를 막는 속도 제한 구역일 뿐, 벽은 아닙니다. 마지막 부분은 역할 분담을 통해 닫았습니다.
**공유 서버에 대한 쓰기 실행은 사람이 누릅니다.** Claude가 하는 것은 드라이런까지만 했습니다.
- Claude가 드라이런(`--apply` 없음)을 실행합니다. 출력은 `tail`로 잘리지 않습니다 - Claude가 마지막 줄의 '복사 목적지:', 건수, 동명 파일 유무를 보고합니다.
- Claude가 `--apply`를 붙인 실행 명령어를 단 하나만 제시하고 멈춥니다. 경로는 숫자를 직접 써서 단일 따옴표로 감싸고, 한 가지 가지에 하나의 명령어만 사용합니다 - 사람이 복사 목적지를 눈으로 확인하고 자신의 터미널에서 실행합니다.
- Claude가 출력의 대조 결과를 읽어 확인하고 다음 단계로 진행합니다.
이 분담 역시 훅에 의해 지지받고 있습니다. Claude가 복사용 스크립트에 `--apply`를 붙여 실행하면, 훅이 막아서 위의 절차로 돌아갈 이유를 제시합니다.
삭제도 같은 방식으로 생각했다. **공유 서버 상의 삭제는, 동의를 얻어도 Claude는 하지 않는다.** 지워야 할 것의 전체 경로(full path)를 보여주고 사람이 직접 지운다.
이 삭제 규칙을 메모리에 명문화한 것은 이번 정리 과정에서였다. 실수로 만들어진 폴더에 대해 Claude가 “지우면 좋겠으면 말해주세요”라고 물었다. 나는 직접 지우고, “너는 못 지우는 규칙이지?”라고 대답했다. 공유 서버의 삭제는 휴지통에 들어가지 않는다. 듣기만 해도 ‘예’라고 하면 사라진다면, 그것은 사람이 판단한 것이 아니게 된다.
## 요약
- AI가 작성한 명령어의 `\`
이 bash에 도달하기 전에 `\`로 절반화되는 경우가 있다. 경로를 이중 백슬래시와 셸 변수로 조합하지 않으면 - 부분 일치 검사는 “거의 같은 것”을 통과한다. 프로젝트, 브랜치, 공정은 계층의 완전 일치로 봐야 한다.
- 만들 수 있는 계층을 정한다. `mkdir(parents=True)`는 존재해서는 안 될 부모 폴더까지 조용히 만든다 - 검증이 OK여도, 불필요한 것이 섞여있지 않다는 보장은 없다. 보고 있는 것이 “부족한 것”만인지 확인해야 한다.
- 약속을 적어도, 스크립트가 그대로 작동한다는 보장은 없다. ‘덮어쓰지 않는다’고 했는데, 경고를 내보내며 덮어쓰기도 했다.
- 후크의 오탐지는 과거 로그를 재생하여 막는다. 그래도 막지 못하는 경로가 남아있기 때문에, 마지막에는 “실행은 사람이 누른다”는 역할 분담으로 마무리한다.
## 관련 기사
**“OK”가 실제로 무엇을 확인하고 있었는지 추적한 다른 글.**
### 토론

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기