파일 시스템 접근 권한을 가진 로컬 AI 에이전트를 위한 샌드박싱 (Sandboxing) 패턴
요약
로컬 AI 에이전트에게 파일 시스템 쓰기 권한을 부여할 때 발생할 수 있는 보안 위험을 분석하고, 이를 방지하기 위한 샌드박싱 패턴을 제안합니다. 모델의 의도치 않은 실수로부터 시스템을 보호하기 위해 '허용 목록(Allowlist)' 기반의 접근 제어와 경로 정규화의 중요성을 강조합니다.
핵심 포인트
- 로컬 에이전트의 주요 위험은 악의성보다 모델의 '자신감 있는 어리석음'에 있음
- 차단 목록(Denylist) 대신 명시적인 루트 허용 목록(Allowlist) 사용 권장
- 경로 검사 전 반드시 경로 정규화(Canonicalization)를 수행하여 경로 조작 방지
- 심볼릭 링크를 통한 권한 우회 가능성을 경계해야 함
얼마 전 저는 여러분의 코드 위에서 로컬 AI 에이전트 (Local AI agents)를 실행하는 것에 대해 글을 썼고, 이는 제가 발표한 글 중 가장 많이 읽힌 게시물이 되었습니다. 압도적으로 가장 많이 들어온 후속 질문은 이것이었습니다: "좋아요, 하지만 디스크에 쓰기 권한 (write access)을 주면 무섭지 않나요?"
조금은 무서워해야 합니다. 실제 파일 시스템 도구 (filesystem tools)를 갖춘 로컬 에이전트를 처음 사용했던 첫 주에, 이 에이전트는 제가 건드려 달라고 요청하지 않은 설정 파일을 다시 작성함으로써 디렉토리를 "정리"해 버렸습니다. 아무것도 잃어버리지는 않았고 git이 저를 지켜주었지만, 저는 diff를 바라보며 이렇게 생각했습니다: '이 녀석은 단 세 글자 차이로 .env 파일을 편집할 뻔했구나.' 7b 모델은 당신에게 해를 끼치기 위해 악의적일 필요가 없습니다. 그저 쓰기 권한 (write permissions)을 가진 상태에서 단 한 번만 자신 있게 틀리면 됩니다.
그래서 이것이 후속 글입니다. 제가 숨을 죽이지 않고도 로컬 에이전트에게 파일 시스템 접근 권한을 부여하기 위해 실제로 사용하는 패턴들입니다. 이 중 특별한 것은 없습니다. 모두 제가 스마트 컨트랙트 (smart contracts)에 적용하는 것과 동일한 지루한 보안 사고방식을 내부로 돌린 것뿐입니다.
위협 모델 (threat model)은 생각보다 단순합니다
클라우드 에이전트 (cloud agents)의 경우 사람들은 프롬프트 인젝션 (prompt injection)과 데이터 유출 (exfiltration)을 걱정합니다. 로컬에서도 이러한 문제는 중요하며, 특히 에이전트가 신뢰할 수 없는 파일을 읽는 경우 더욱 그렇습니다 (클론된 저장소는 에이전트를 겨냥한 텍스트를 포함할 수 있으며, 저는 Argus Lens의 저장소 스캐너 작업을 통해 실제 사례들을 보고 있습니다). 하지만 로컬 에이전트의 지배적인 위험은 더 일상적입니다: 모델이 오해를 하고, 그 오해가 당신의 권한으로 실행되는 것입니다.
잘못된 파일에 대한 올바른 작업. 올바른 파일에 대한 너무 광범위한 작업. 예상치 못한 곳으로 해결되는 경로. 방어 계획을 세울 때 악의 (malice)보다는 자신감 넘치는 어리석음 (confident stupidity)을 첫 번째로, 악의를 두 번째로 고려한다면 대부분의 위험을 커버할 수 있을 것입니다.
패턴 1: 경로 차단 목록 (denylist paths) 대신 루트 허용 목록 (allowlist roots) 사용하기
본능적으로는 /etc가 아닌 곳, 홈 설정이 아닌 곳처럼 위험한 장소를 차단하려고 합니다. 차단 목록 (Denylists)은 항상 그렇듯 예외 케이스를 잊어버리는 방식으로 실패합니다. 이를 뒤집으세요. 에이전트에게는 건드릴 수 있는 디렉토리의 명시적인 목록을 제공하고, 그 외의 모든 것은 기본적으로 거부(denied by default)되도록 합니다.
const ALLOWED_ROOTS = [
"/home/pavel/projects/current-audit",
"/tmp/agent-scratch",
...
저에게는 두 개의 루트(root)가 전형적입니다. 작업 중인 프로젝트와 에이전트가 자유롭게 망가뜨려도 되는 스크래치(scratch) 디렉토리입니다. 그게 전부입니다. 계약(contract)에 제한 없는 delegatecall이 필요하지 않은 것과 마찬가지로, 에이전트에게 사용자의 홈 디렉토리 전체가 필요하지는 않습니다.
중요한 구현 세부 사항은 경로를 확인하기 전에 경로를 해결(resolve)하는 것입니다. projects/current-audit/../../.ssh/id_ed25519는 단순한 접두사(prefix) 검사를 통과합니다. 먼저 정규화(Canonicalize)한 다음 비교해야 하며, 심볼릭 링크(symlink)는 의심스럽게 다뤄야 합니다. 허용된 루트 내부의 심볼릭 링크가 어디든 가리킬 수 있기 때문입니다.
패턴 2: 허용된 루트 내부라도 기본적으로 도트파일(dotfiles)과 비밀 정보(secrets)는 거부하기
허용된 프로젝트 디렉토리 내부에도 에이전트가 건드릴 이유가 없는 파일들이 여전히 존재합니다. 저의 규칙은 다음과 같습니다. 점(.)으로 시작하는 모든 파일과 알려진 비밀 정보를 포함하는 이름은, 제가 세션별로 명시적으로 권한을 부여하지 않는 한 에이전트에게 보이지 않도록 합니다.
.env가 가장 명백한 예입니다. 또한 .git ( .git에 내용을 쓰는 에이전트는 저장소를 손상시키거나, 더 나쁘게는 훅(hooks)을 심을 수 있습니다), 자격 증명(credentials) 파일, 키 자료(key material) 등이 포함됩니다. 쓰기뿐만 아니라 읽기도 거부해야 합니다. .env를 읽을 수 있는 에이전트는 당신의 API 키를 생성된 파일, 커밋 메시지, 또는 나중에 당신의 기기를 떠나게 될 요약본에 아무렇지 않게 붙여넣을 것입니다.
패턴 3: 차이점(diff)을 출력하는 드라이런(dry-run) 모드
제 설정에 있는 모든 쓰기 도구(write tool)에는 실제로 쓰지 않는 모드가 있습니다. 이 모드는 수행할 작업을 통합 차이점(unified diff) 형식으로 출력하고 멈춥니다. 새로운 에이전트, 새로운 프롬프트, 새로운 모델 버전이 도입될 때마다, 제안된 변경 사항을 충분히 지켜보고 그 조합을 신뢰할 수 있을 때까지 드라이런 모드를 유지합니다.
차이점(diff) 형식이 중요합니다. "config.ts를 업데이트하겠습니다"라는 말은 아무런 정보도 주지 않습니다. 실제 변경 전후의 라인을 직접 확인하는 덕분에, 저는 첫 주에 설정 파일이 재작성되는 것을 잡아낼 수 있었습니다. 구축 비용은 저렴하면서도, "나를 믿으세요"를 "나를 확인하세요"로 바꿔줍니다.
패턴 4: 참조 자료를 위한 읽기 전용 바인드 마운트(read-only bind mounts)
에이전트는 종종 쓰기 권한이 있어서는 안 되는 것들, 즉 의존성 소스(dependency sources), 참조 리포지토리(reference repo), 문서 트리(documentation trees) 등을 읽어야 할 때가 많습니다. 이러한 항목들을 허용 목록(allowlist)에 추가하고 요행을 바라는 대신, 읽기 전용(read-only)으로 마운트하세요:
mkdir -p /home/pavel/agent-ro/reference-repo
sudo mount --bind -o ro /home/pavel/projects/reference-repo /home/pavel/agent-ro/reference-repo
이제 강제 적용(enforcement)은 나의 TypeScript가 아닌 커널(kernel) 수준에서 이루어집니다. 설령 나의 래퍼(wrapper)에 버그가 있더라도, 해당 트리에 대한 쓰기 시도는 운영체제(OS) 수준에서 실패합니다. 심층 방어(Defense in depth)란 첫 번째 계층이 놓친 것을 두 번째 계층이 잡아내는 것을 의미합니다. 더 나아가고 싶다면, 명시적인 볼륨 마운트(volume mounts)를 포함한 컨테이너(container) 내에서 에이전트 전체를 실행하여 동일한 속성에 프로세스 격리(process isolation)까지 얻을 수 있지만, 바인드 마운트(bind mount)는 1분 안에 설정할 수 있는 80% 수준의 효율적인 방법입니다.
패턴 5: 폭발 반경(blast radius) 체크리스트
에이전트에게 어떤 도구(tool)를 활성화하기 전에, 저는 다음 다섯 가지 질문에 대해 글로 답합니다:
- 이 도구가 수행할 수 있는 최악의 단일 호출(single call)은 무엇인가?
- 그 최악의 상황이 되돌릴 수 있는가? (git으로 추적되는 파일: 예. 리포지토리 외부에서의
rm, 또는 푸시된 커밋: 아니요.) - 이 도구가 읽을 수 있는 것은 무엇이며, 그중 비밀(secret)이 될 만한 것이 있는가?
- 이 도구의 출력이 나중에 발생할 더 위험한 호출에 영향을 미칠 수 있는가? (읽기 도구가 쓰기 도구에 데이터를 공급한다면, 파일 내용을 통한 인젝션(injection) 가능성이 존재함)
- 작업을 수행하면서도 범위를 가장 좁게 유지할 수 있는 범위(scope)는 어디인가?
질문 2에 대한 답이 "되돌릴 수 없음(irreversible)"이라면, 해당 도구는 활성화되지 않거나 인간의 확인 게이트(human-confirmation gate)를 거치게 됩니다. 이것은 제가 계약의 외부 호출(external calls)을 검토할 때 생각하는 방식과 정확히 일치하며, 다음과 같이 깔끔하게 적용됩니다: 문제가 발생한 후가 아니라, 실행되기 전에 무엇이 잘못될 수 있는지 열거하십시오.
정책을 강제하는 래퍼(wrapper)
다음은 모든 파일 시스템 도구가 거치는 래퍼의 축약된 버전입니다. 핵심은 구조(shape)에 있습니다. 정책이 거주하는 단일 초크 포인트(choke point)를 두어, 개별 도구들은 정책으로부터 자유롭게 유지되도록 합니다.
import { realpath } from "node:fs/promises";
import path from "node:path";
...
참고로 realpath는 루트 검사 이전에 심볼릭 링크(symlinks)를 해석하며, 접근 거부 오류(denial errors)는 모델에게 도구 결과(tool results)로 반환됩니다(모델은 '허용된 루트 외부입니다: denied'라는 지시를 받으면 실제로 적응합니다). 그리고 real 버전은 git이 추적되지 않은 파일(untracked files)을 커버하지 않기 때문에 쓰기 전에 모든 파일을 백업합니다.
이러한 것들 중 어느 것도 에이전트를 절대적으로 안전하게 만드는 것은 아닙니다. 이것이 하는 일은 단일한 잘못된 결정으로 인한 피해 범위를 당신이 의식적으로 선택하고 복구할 수 있는 공간으로 제한하는 것입니다. 샌드박싱(sandboxing)이 항상 그래왔던 것이 바로 그것이며, 이 도구들을 주저하지 않고 사용할 수 있게 하기에 충분합니다.
현재 에이전트 설정에서 가장 큰 피해 반경(blast radius)을 가진 도구는 무엇이며, 실제로 이를 기록해 두었습니까?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기