AI 에이전트에게 PC를 맡길 때의 세 가지 가드레일
요약
AI 에이전트가 PC 환경에서 코드를 작성하고 자료를 조사하는 과정에서 발생할 수 있는 보안 및 운영상의 위험 사례와 이를 방지하기 위한 '가드레일' 구축 방법을 제시합니다. 특히, 민감 정보 유출, 시스템 프로세스 오작동, 파일 무단 삭제 등 에이전트의 광범위한 접근 권한을 제어하는 것이 핵심입니다.
핵심 포인트
- 에이전트는 사람이 하는 것과 같은 조작(파일 읽기, 셸 명령어)이 가능하며 속도가 빠릅니다.
- 민감 정보 유출 방지를 위해 단순 규칙 명시가 아닌 'Hook' 기반의 기술적 메커니즘이 필요합니다.
- 프로세스 오작동 방지에는 PID와 시작 시간 등 다중 검증을 통해 정확한 대상을 식별해야 합니다.
- 파일 삭제 시 되돌릴 수 없으므로, 참조 확인 및 제한된 범위 내에서만 작업을 수행하도록 설계해야 합니다.
안녕하세요, 미오입니다. 저와 AI 에이전트가 함께 작은 회사를 운영하고 있습니다.
하나의 Windows PC 위에서 코드를 작성하는 에이전트, 자료 조사를 하는 에이전트, 정기적으로 작동하는 감시 스크립트가 동시에 움직이고 있습니다. 실제 서비스에 상주하는 처리(常駐の処理)도 같은 PC 위에 있습니다.
이 글은 그 PC에서 실제로 발생했거나 발생할 뻔했던 사고를 바탕으로 만든 '가드레일'에 대한 이야기입니다. 이전 두 편에서는 외부 모델로 비밀 정보를 보내지 않는 문과, 공개 전 유출 검사에 대해 작성했습니다. 이번에는 좀 더 가까운 곳, 즉 에이전트의 손이 PC 내부 어디까지 닿을 수 있는지를 다룹니다.
먼저 결론부터 말씀드리겠습니다.
| 발생한 일 | 위험했던 부분 | 마련한 가드레일 |
|---|---|---|
에이전트가 자료 조사 도중 .env 파일을 grep하여 비밀 값이 작업 기록에 남음 | 작업 기록은 외부 서비스로 전송됨 | 읽는 도구의 입구에서 경로를 확인하고 거부하는 Hook |
자신의 시험을 중단시키기 위해 python.exe를 이름으로 일괄 정지함 | 실제 서비스 상주 처리도 python 사용 | 시작 시 PID와 시작 시간을 기록하여, 둘 다 일치하는 것만 정지시킴 |
| 정리 도구가 링크의 끝이나 정기 작업이 사용하는 장소까지 삭제함 | 삭제된 것은 되돌릴 수 없음 | 놓는 장소 바로 아래만 / 링크는 따라가지 않음 / 참조가 읽히지 않으면 아무것도 삭제하지 않음 |
위 모든 경우에 에이전트에게 악의가 있었던 것은 아닙니다. 눈앞의 작업을, 손쉬운 도구로, 빨리 처리하려 한 결과였습니다. 그래서 '주의해 주세요'라고 요청서에 적는 것만으로는 막을 수 없었습니다.
전제: 에이전트의 손은 생각보다 길다
우선 왜 이 세 가지인지부터 말씀드리겠습니다.
코드를 작성하는 에이전트는 파일을 읽고, 셸 명령어를 입력하고, 프로세스를 시작하고 중지하며, 파일을 삭제할 수 있습니다. 사람이 하는 것과 같은 조작입니다. 다른 점은 다음 두 가지입니다.
- 속도와 횟수: 사람이 하루에 몇 번밖에 사용하지 않는 명령어를 에이전트는 한 시간에 수십 번이나 입력합니다. 확률이 낮은 사고도, 횟수로 인해 발생할 수 있습니다. -
문맥의 희박함: 에이전트는 '이 PC에서他に何が動いているか(다른 무엇이 돌아가고 있는지)'를 알지 못합니다. 요청받은 작업 범위만 보고 있습니다.
사람이 혼자 사용하는 PC라면, '.env는 보지 않는다', 'taskkill /IM는 사용하지 않는다'는 암묵적인 상식으로 충분할 수 있습니다. 하지만 에이전트가 여러 대 움직이는 PC에서는, 그 상식을 기계가 지키는 형태로 만들지 않으면 언젠가 깨집니다.
가드레일 1: 비밀을 읽게 하지 않기
발생한 일
실제 서비스 처리 관련 자료 조사를 부탁받은 에이전트가, 설정을 확인하는 도중에 .env 파일을 grep했습니다. 설정이 어떻게 되어 있는지 보려면, 설정 파일을 여는 것은 자연스러운 수순입니다.
결과적으로, .env에 평문으로 적혀 있던 비밀 값이 에이전트의 작업 기록에 남았습니다. 나중에 점검해보니 같은 일이 다른 날에도 발생했었습니다(나온 키는 모두 교체 대상입니다).
문제는 에이전트의 작업 기록은 모델을 구동하는 외부 서비스로 전송된다는 것입니다. 눈앞 화면에 나온 것만으로는 안 됩니다.
'읽지 마라'라고 적기만 해서는 부족한 이유
프로젝트 설명 파일(Claude Code의 경우 CLAUDE.md)에는, '키나 비밀번호를 로그나 알림에 노출하지 않는다'라는 규칙이 이미 작성되어 있었습니다.
그럼에도 불구하고 나온 이유는, 설명 파일의 문구는 조언일 뿐, 도구 실행을 멈추게 하는 메커니즘이 아니었기 때문입니다. 에이전트는 '설정을 확인한다'는 눈앞의 목적에 더 집중합니다. 설정 파일을 여는 것은 그 목적에 가장 손쉬운 방법입니다. 그리고 열리는 순간, 내용은 이미 작업 기록에 남습니다.
막으려면, 도구의 입구에서 막아야 합니다.
보관 장소를 분리하고 프로그램만 읽게 하기
먼저, 비밀의 보관 장소를 작업 공간에서 멀리 떨어뜨립니다.
- 키는 에이전트가 평소 작업하는 리포지토리 외부 폴더에 둡니다.
- 실제 서비스 프로그램은 시작 시 그 파일을 읽습니다(
load_dotenv등으로). 값은 화면에도 로그에도 노출하지 않습니다. - 에이전트가 할 수 있는 것은 '파일이 존재하는지' 확인(ls나Test-Path)까지만 합니다
프로그램이 키를 읽는 것과, 에이전트가 키를 화면에 보여주는 것을 분리해서 생각하는 것이 중요합니다. 전자는 막을 필요가 없습니다. 막아야 하는 것은 후자뿐입니다.
거부 설정
Claude Code에는 도구별로 거부 규칙을 작성할 수 있는 설정이 있습니다. 첫 단계로는 이것으로 충분합니다.
⚠️ [IMG:N] 형식 토큰은 이미지 placeholder 입니다. 번역하지 말고 원래 위치에 그대로 유지하세요.
{ "permissions": {"deny": [
...
하지만, 이것은 `Read`라는 도구에 적용되는 규칙입니다. 셸에서 `cat .env`나 `grep PASSWORD .env`를 입력하면 다른 도구이므로 무시됩니다. 실제로 기록된 경로 중 셸을 거친 것은 이 방식으로는 막을 수 없습니다.
### 진입점 후크 (Entry Hook)
그래서, 도구가 실행되기 직전에 호출되는 후크(Claude Code에서는 `PreToolUse`)에서 입력에 있는 경로와 명령어를 확인합니다. 저희가 운영하는 것을 일반화한 것이 다음 코드입니다.
""" 에이전트가 도구를 사용해 비밀 파일을 화면에 출력하는 것을 막는다.
막는 대상: Read / Grep의 대상이 비밀 경로이거나, 셸에서 같은 줄에 「내용을 출력하라는 명령」과 「비밀 경로」가 나란히 있는 경우.
...
{ "hooks": {"PreToolUse": [
...
거부 이유 문구는 에이전트에게 그대로 반환됩니다. 여기에 **대신 어떻게 해야 하는지**를 적어두면, 에이전트는 다른 우회로를 찾기보다 올바른 방법(프로그램에 읽게 하는 것)으로 넘어갑니다. 이유를 단순히 「금지」라고만 하면, 표현 방식을 바꿔서 같은 것을 시도할 수 있습니다.
### 만들고 나서 알게 된 점 (What we learned after building)
**오탐지로 인해 관계없는 작업이 멈췄습니다.** 초기 버전은 명령어 문자열 어딘가에 `.env`가 있으면 막았습니다. 그러자 에이전트가 보고서 초안을 히어독먼트로 파일에 작성할 때, 본문에 「`.env`를 읽지 않도록 했다」라고 쓰여 있는 것만으로도 멈췄습니다. 위 코드에서 히어독먼트의 본문을 제외한 것은 그 수정 사항입니다.
막는 조건은 **같은 줄에 「내용을 출력하라는 명령」과 「비밀 경로」가 나란히 있을 때**로 좁혔습니다. `.env`라는 단어가 언급된다고 해서 막지는 않습니다. 울타리는 오해로 인해 너무 자주 멈추면, 제거해 달라는 요청이 나오고 결국 제거됩니다.
**입력이 읽을 수 없을 때는 통과시킵니다.** 이것은 고민했던 부분입니다. 후크 입력의 JSON이 손상되었을 때 전부 막으면, 에이전트의 모든 작업이 중단됩니다. 이 울타리의 역할은 「실수」를 막는 것이므로, 통과시키는 쪽으로 결정했습니다. 돈을 다루는 작업의 울타리라면 반대로 멈추게 할 것입니다. 무엇을 보호할 울타리인지에 따라 망설일 때의 방향이 결정됩니다.
**새어 나간 열쇠는 기록을 지워도 돌아오지 않습니다.** 외부로 전송된 작업 기록은 손에서 지울 수 없습니다. 새어 나갔다고 밝혀진 열쇠는 교체합니다. 울타리는 다음 유출을 막는 것이고, 과거의 유출은 열쇠를 교체하는 것으로만 닫을 수 있습니다.
## 울타리 2: 프로세스를 이름으로 막지 않기 (Not blocking processes by name)
### 무슨 일이 일어났나 (What happened)
한 에이전트가 자신이 시작한 테스트가 멈추지 않자, 그것을 멈추려고 했습니다. 입력된 명령어는 다음과 같습니다.
taskkill /F /IM python.exe
`python.exe`라는 이름의 프로세스를 **모두** 강제 종료하는 명령입니다.
이 PC에서는 실제 상주 처리, 감시, 수집 등 모든 것이 python으로 작동합니다. 이 때는 조건 설정상의 이유로 우연히 아무것도 멈추지 않았습니다. 운이 좋았을 뿐입니다.
Linux나 macOS라면 `pkill python`이나 `killall node`가 같은 형태의 명령어입니다.
### 왜 입력하게 되는가 (Why is it entered)
에이전트에게 이것은 논리적인 수단입니다. 「내 테스트의 python이 멈추지 않는다 → python을 멈춘다」 자신이 시작한 프로세스의 번호(PID)를 기억하지 못하면, 이름으로 멈추는 것이 가장 빠릅니다.
즉 근본 원인은 **자신이 시작한 것의 PID를 기록하고 있지 않기**입니다. 기록하고 있다면, 이름으로 찾을 필요가 없습니다.
### PID와 시작 시간을 기록하고, 둘 다 확인하여 멈추기 (Stopping by checking both PID and start time)
시작할 때 PID를 남기고, 멈출 때는 그 파일의 PID만 멈춥니다.
여기서 한 가지 함정이 있습니다. PID는 재사용됩니다. 테스트가 먼저 끝나서 같은 번호가 다른 프로세스(예: 실제 처리)에 할당되어 있다면, PID만으로 멈추면 다른 것을 멈춥니다. 그래서 **시작 시간도 함께 기록**하여, 둘 다 일치할 때만 멈춥니다.
""" 자신이 시작한 프로세스만 멈춘다. 이름으로는 찾지 않는다."""
import json
import subprocess
...
자식 프로세스까지 한 번에 멈추는 것은, 테스트 도중에 더 프로세스를 시작하는 경우, 부모만 멈추고 자식이 남는 것을 방지하기 위함입니다.
### 요청 문구와, 넣을 만한 후크
저희는 지금 에이전트에게 보내는 요청 문구에 매번 '이름 일괄 지정으로 프로세스를 멈추지 마세요. 멈출 수 있는 것은 자신이 기동시킨 PID만입니다'라고 적습니다. 한 번 발생했던 유형이라, 요청 문구의 정형 문구로 넣었습니다.
기계적으로 멈추고 싶을 때는, 가드레일 1과 같은 후크 형태로 충분합니다.
KILL_BY_NAME = re.compile(
r"(?i)\btaskkill\b[^
]/IM\b"
r"|\bStop-Process\b[^
]-Name\b"
...
`Bash`
의 `command`에 이것이 걸리면, '자신이 기동시킨 PID만 멈추세요'라는 이유로 거부합니다.
## 가드레일 3: 지우는 범위를 둘러싸기
세 가지 중, 가장 큰 피해를 입었던 것이 바로 이것입니다.
### 정리 작업이 필요했다
에이전트는 작업마다 git의 작업용 트리(worktree)를 만듭니다. 서로의 작업이 충돌하지 않도록 하기 위해서입니다. 이게 정리가 안 되고 쌓여서, 어느 날은 약 1,300개가 있었습니다.
그 전에는 디스크가 가득 차서 실제 상주 처리(常駐の処理)가 멈췄던 적도 있습니다. 쌓인 것은 지워야 합니다. 지우는 도구를 만들고 매일 실행하기로 했습니다.
그리고, **지우는 도구 자체가 가장 위험하다**는 것을 두 번 배웠습니다.
### 첫 번째: 링크의 끝까지 사라졌다
작업용 트리를 지우려면 `git worktree remove --force`가 순수한 방법입니다. 그런데 손에 든 Windows의 Git으로 이것을 시도했을 때, **트리 안에 있는 ジャンクション(폴더로의 링크)을 따라가서, 링크된 내용물까지 지운다**는 것을 알게 되었습니다.
작업용 트리 안에는 매번 환경 구축 과정을 생략하기 위해, 실제 가상 환경(Python의 venv)을 가리키는 링크를 넣어둔 것이 있었습니다. 그것을 `git worktree remove --force`로 지우자, 실제 가상 환경의 내용물이 사라졌습니다.
수정은, 트리의 삭제를 git에 맡기지 않고, 링크를 따라가지 않는 방식으로 지워서 관리 정보만 git에 정리하게 하는 것입니다.
- Python의 `shutil.rmtree`는 3.8 버전 이후부터 Windows의 ジャンクション 내부를 따라가지 않고 링크만 지웁니다 - 다 지우고 나면 `git worktree prune`으로, git 측의 관리 정보를 정리합니다
### 두 번째: 정기 작업이 사용하던 곳을 지웠다
첫 번째 수정 후, 정리 작업을 돌렸습니다. 그러자 다음 날, 감시하는 정기 작업 3개가 멈춰 있는 것을 알게 되었습니다. 긴 것은 약 10시간 동안, 아무도 눈치채지 못하게 멈춰 있었습니다.
원인은, 그 정기 작업이, 작업용 트리 안의 스크립트를 **직접** 실행하고 있었기 때문입니다. 누군가가 작업용 트리에 작성한 스크립트를, 그대로의 장소에 정기 작업으로 등록해 두었습니다. 트리를 지우자, 실행할 파일이 없어졌습니다.
정리 도구는 '이 트리는 더 이상 아무도 사용하지 않는다'고 판단했습니다. 사용하고 있는지 여부를, git 정보에서만 보고 있었기 때문입니다.
### 지워도 좋을 조건을 모두 '충족하면 지운다'로 변경하기
두 번의 사고를 통해, 지우기 전 확인 과정을 다음과 같이 했습니다. 전부 충족했을 때만 지우고, 하나라도 빠지면 지우지 않습니다.
- 삭제 대상이 정한 위치의 **바로 아래**에 있다
- 대상 자체가 링크가 아니다
- 위치까지 가는 중간에도 링크가 없다
- 실체의 경로가 회사 폴더 안에 있다
- git에 등록된 작업용 트리이며, 잠겨 있지 않다
- 정기 작업 설정에 그 경로가 나타나지 않는다
- 정기 작업 설정이 **읽을 수 없으면, 아무것도 지우지 않는다**
7번이 중요합니다. '참조를 조사했더니 아무것도 없었다'와 '참조를 조사할 수 없었다'는 다릅니다. 후자를 전자로 취급하면, 조사하는 수단이 고장 난 날에, 전부를 지웁니다.
"""위치 바로 아래의 작업용 트리만, 링크를 따라가지 않고 지운다."""
import os
import shutil
...
`scheduled_refs`에는, 정기 작업 설정에서 '실행할 것・인수・작업 폴더' 전부를 이은 문자열을, `norm`과 같은 형태(소문자・구분자를 맞춘 것)로 전달합니다. Windows의 경우 작업 스케줄러 목록에서 가져옵니다.
Linux의 경우 `crontab -l`이 동일한 역할을 합니다. 이것이 실패하면 `None`을 전달하고, 위의 코드는 아무것도 지우지 않습니다.
또한, **한 번에 삭제할 수 있는 개수에 상한선**을 두었습니다. 조건 작성 오류로 인해 모든 항목에 해당될 경우, 피해를 상한선으로 제한하기 위함입니다. 상한선에 도달하면 그 시도는 멈추고 기록이 남습니다.
### 보관 장소 규칙도 변경했습니다
코드의 울타리 외에도 보관 장소의 규칙을 바꿨습니다. **정기 작업에서 실행되는 스크립트는 작업용 트리에 두지 않습니다.** 정기 작업을 위한 전용 장소를 만들고, 그곳은 정리 대상에서 제외했습니다.
삭제 도구 측면에서 '사용 중인지'를 확인하는 것은 마지막 안전망입니다. 애초에 삭제되어도 좋은 곳에, 삭제되면 곤란한 것을 두지 않는 것이 우선입니다.
## 추가: git의 파괴적인 작업
울타리 3과 같은 생각으로, git 작업도 제한했습니다.
여러 에이전트가 동일한 작업 트리를 공유할 경우, 한 사람의 `git reset --hard`이나 `git checkout`은 다른 에이전트가 아직 커밋하지 않은 작업을 지워버릴 수 있습니다. 저희는 공유 트리에서 이러한 작업을 후크로 거부하고, 각 에이전트는 자신만의 전용 작업 트리에서 작업하도록 했습니다.
DESTRUCTIVE_GIT = re.compile(
r"\bgit\b[^
;&|]\b(reset\b[^
;&|]--hard|checkout|switch)\b"
)
`git push --force`와 `--no-verify`는 원칙적으로 전면 금지했습니다. force push는 타인의 이력을 지우고, `--no-verify`는 본 기사에 작성된 것과 같은 검사 후크를 건너뜁니다. 울타리를 우회하는 수단 자체를 금지하지 않으면, 그 울타리는 의미가 없습니다.
## 추가: 외부 도구를 도입하기 전에
에이전트는 편리해 보이는 외부 패키지나 도구를 발견하면 도입하려 합니다. 도입하기 전에 거치는 5가지 점검(출처・버전 고정・격리 테스트・최소 권한・제공 데이터 제한)은 이전 기사에 작성했으므로, 여기서는 직접 사용하는 구체적인 방법을 나열하겠습니다.
Python: 버전과 해시를 고정하고, 해시가 일치하지 않으면 도입하지 않음
pip-compile --generate-hashes requirements.in
pip install --require-hashes -r requirements.txt
...
MCP의 서버나 도구 권한은 **읽기 전용(Read only)**부터 시작합니다. 쓰기나 외부 전송은, 어느 정도 사용해 내용을 확인한 후에 추가합니다. 처음에 모든 권한을 주고 나중에 줄이는 방식은 미루어지기 쉽습니다.
## 체크리스트
자신의 환경에서 다음 항목들을 하나씩 점검해 보세요.
### 비밀(Secrets)
- 키 파일은 에이전트가 작업하는 리포지토리 외부에 있다
- 에이전트의 `Read` 도구에, 비밀 경로 거부 규칙이 있다 -
셸 경유(`cat`, `grep`, `type`, `Get-Content`) 읽기 역시 후크로 막힌다 - 거부 이유에 '대신 어떻게 할지'가 적혀있다
- 과거 작업 기록을 비밀 값으로 검색한 적이 있다. 나온 키는 교체했다
### 프로세스(Process)
- 에이전트가 실행한 프로세스는 PID와 시작 시간이 기록된다
- 중단할 때는, 기록된 PID와 시작 시간이 모두 일치하는 것만 중단한다
- 이름으로 일괄 중단(`taskkill /IM`, `pkill`, `killall`, `Stop-Process -Name`)을 요청서나 후크로 금지하고 있다
### 삭제(Deletion)
- 삭제 도구는 정해진 보관 장소 바로 아래만 지운다
- 링크를 따라가지 않는 방식으로 삭제한다(`git worktree remove --force`를 Windows에서 사용하지 않는다) - 정기 작업이 참조하는 곳은 지우지 않는다. 참조가 읽을 수 없으면 아무것도 지우지 않는다
- 한 번에 삭제할 개수에 상한선이 있다
- 정기 작업에서 실행되는 것은, 지워질 수 있는 장소에 두지 않는다
### git과 외부 도구
- 공유 트리에서 `reset --hard`
・`checkout`
・`switch`
을 막고 -
force push와
`--no-verify`
를 금지하고 -
의존성은 버전과 해시로 고정합니다. 넣을 때의 스크립트를 읽었습니다.
## 맺음말
세 가지 모두 처음에는 요청 문장 한 줄이면 충분하다고 생각했습니다. '`.env`는 읽지 않는다', '이름으로 막지 않는다', '사용하는 것은 지우지 않는다'. 이 모든 것은 사람이라면 한 번 들으면 지킬 수 있는 것입니다.
에이전트는 한번 들은 것을 잊어버리는 것이 아닙니다. **눈앞의 목적이 더 강하기 때문입니다.** 설정을 확인하거나, 멈추지 않는 시험을 멈추거나, 쌓인 나무를 치우는 것. 그 목적에 가장 가까운 손길이 우연히 위험한 손길이었던 것입니다.
그래서 울타리는 에이전트의 판단 앞이 아니라, **도구의 입구**에 놓습니다. 그리고 울타리가 막았을 때는 이유와 함께 '대체할 올바른 방법'을 돌려줍니다. 그러면 에이전트는 울타리를 피해 돌아가는 것이 아니라, 울타리 안에서 작업을 계속해 줍니다.
마찬가지로 여러 에이전트를 하나의 PC에서 구동하고 계신 분들께 참고가 되었으면 좋겠습니다.
### 토론 (Discussion)

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