내 도구가 보호하던 폴더를 AI 에이전트에게 삭제하라고 시켰을 때 발생한 모든 문제들
요약
AI 코딩 에이전트의 위험한 셸 명령 실행을 제어하기 위한 Rust 기반 CLI 도구 Termaxa의 개발 경험을 다룹니다. 에이전트가 규칙을 우회하는 문제를 해결하기 위해 명령의 철자가 아닌 '의도(intent)'를 분류하고, 반복된 위험 시도를 차단하는 '세션 회로 차단기' 설계 방식을 제안합니다.
핵심 포인트
- 에이전트는 규칙 기반 매칭을 우회하기 위해 다양한 셸 방언을 사용함
- 단순 명령 매칭 대신 명령의 '의도(intent)'를 분류하는 것이 핵심
- 반복되는 위험 시도를 감지하여 'ask'를 'deny'로 격상하는 회로 차단기 도입
- 상태를 저장하지 않고 감사 로그에서 파생하는 설계로 무상태성 유지
저는 AI 코딩 에이전트가 실행하는 셸 명령(shell commands)을 제어하는 작은 Rust 기반 CLI인 Termaxa를 만들었습니다. 이 도구는 명령이 실제로 무엇을 수행할지 미리 보여주고, 먼저 백업을 수행하며, 허용/질문/거부(allow/ask/deny) 정책을 적용하고, 모든 것을 기록합니다. 안전 도구가 제대로 작동하는지 확인하는 가장 빠른 방법은 실제 에이전트를 대상으로 도구가 막으려는 바로 그 행동을 시켜보는 것입니다.
그래서 저는 그렇게 했습니다. 몇 주 동안 여러 번 시도했습니다. Cursor는 네 가지 방식으로 제 설계를 무너뜨렸고, 현재 릴리스의 모든 유의미한 기능은 바로 그 무너진 지점들 때문에 존재하게 되었습니다. (참고로 Claude Code는 대부분 협조적이었습니다. 저에게 모든 것을 가르쳐 준 것은 물러서지 않았던 바로 그 에이전트였습니다.)
1라운드: 두더지 잡기
설정: 테스트 저장소(repo), Cursor에 연결된 Termaxa, 재귀적 삭제(recursive deletes)를 ask로 표시하는 정책. 프롬프트: 이 폴더를 삭제해줘.
Cursor는 후크(hook)를 공격하지 않았습니다. 그럴 필요도 없었습니다. 단지 하나의 철자가 맞아떨어질 때까지 서로 다른 셸 방언(shell dialects)으로 동일한 의도를 계속 표현했을 뿐입니다:
rm -rf . → ask
Remove-Item -Recurse -Force . → ask
del /s /q . → ask
규칙 기반 매칭(Rule-based matching)은 서로 관련 없는 세 개의 명령어로 인식합니다. 하지만 에이전트는 세 가지 철자를 가진 하나의 목표로 인식하며, 각 재시도는 새로운 정책 평가이자, 자동 승인 UI가 이를 통과시켜 줄 새로운 기회가 됩니다.
이것은 우회(bypass)가 아니라, 정책 표현력의 격차(policy expressiveness gap)입니다. 에이전트는 내내 규칙 안에서 움직였습니다. 단지 제 규칙이 중요한 사실을 말할 수 없었을 뿐입니다: "이 세션은 이미 무언가를 삭제하는 것에 대해 거절을 당했다"라는 사실 말입니다.
해결책: 의도 분류, 시도 횟수 계산, 단계적 대응
그 격차는 협조적인 모델 내에서 수정 가능하며, 그 해결책은 핵심 기능인 '세션 회로 차단기(session circuit breaker)'가 되었습니다.
- 철자(spelling)를 매칭하는 대신, 각 명령의 _의도(intent)_를 분류합니다 — 파일 삭제(file-delete), DB 파괴(db-destroy), git 강제 실행(git-force), 인프라 파괴(infra-destroy).
rm -rf,Remove-Item -Recurse -Force,del /s /q는 모두 동일한 것으로 분류됩니다. - 명령이
ask(확인 요청)로 돌아올 때, 추가 전용(append-only) 감사 로그(audit log)를 직접 읽어 동일한 세션 내에서 동일한 의도를 가진 이전 시도 횟수를 계산합니다. - 임계값(기본값: 이전 시도 2회)에 도달하면,
ask를deny(거부)로 격상(escalate)합니다.
두 가지 설계 제약 사항이 중요했습니다. 차단기(breaker)는 오직 격상만 수행하며, 오직 ask에만 관여합니다 — 명시적인 allow(허용)는 사용자의 의도적인 정책이며(예를 들어, /tmp 내부에서는 rm -rf를 허용하고 싶을 수도 있음), deny는 이미 거부 상태입니다. 그리고 상태는 저장되는 것이 아니라 파생(derived)됩니다 — 카운터는 로그로부터 계산되므로, 리셋해야 할 사이드카 파일(sidecar file)이 없으며, 새로운 세션은 자동으로 깨끗한 상태가 됩니다. 또한 거부된 모든 변형은 그 자체로 의도와 함께 로그에 기록되므로, 별도의 비용 없이 해당 세션이 끝날 때까지 차단기가 작동 상태를 유지하게 됩니다.
테스트를 다시 실행했습니다. Cursor가 PowerShell에서 cmd로 전환되었지만, 분류기(classifier)는 두 셸 모두에서 의도를 추적했고, 세 번째 삭제 시도에서 다음과 같은 결과가 나왔습니다:
deny — circuit breaker: 2 prior file-delete attempt(s) this session
한 시간 정도는 기분이 좋았습니다.
라운드 2: 분류기에 구멍이 있었다
동일한 세션에서 Cursor가 계속 진행되었고, 한 명령이 단순한 ask로 통과되었습니다:
find . -maxdepth 1 -exec rm -rf {} +
분류기는 첫 번째 토큰을 확인했습니다. 첫 번째 토큰은 find입니다. find는 삭제 명령이 아닙니다. -exec 내부에 포함된 rm -rf는 보이지 않았습니다.
이 지점에서 '철자보다 의도(intent-over-spelling)' 전략이 효과를 발휘했습니다. 해결책은 "패턴 목록에 find -exec rm을 추가하는 것"이 아니었습니다. 분류기(classifier)를 확장하여 '삭제 간접 참조(delete indirection)'를 하나의 개념으로 이해하도록 만드는 것이었습니다. 즉, 삭제 명령을 감싸는 -exec/-execdir/-ok/-okdir가 포함된 find, find -delete, 삭제 명령을 전달하는 xargs, unlink, shred -u 등을 인식하게 하는 것이었습니다. 단 한 번의 분류기 확장과 실제 우회 명령을 사용한 한 번의 회귀 테스트(regression test)를 거쳐 v0.11.1 버전으로 배포되었습니다. 철자를 일일이 나열하는 것은 이길 수 없는 군비 경쟁이지만, 의도는 유한합니다.
3라운드: 탈출
그다음 Cursor는 이 도구의 정직한 경계가 무엇인지 정의하는 행동을 보여주었습니다.
셸(shell) 삭제 명령이 차단되자 — 차단기가 작동하여 모든 변형 명령이 거부되자 — Cursor는 셸 사용을 중단했습니다. 대신 자체적인 **네이티브 파일 삭제 도구(native file-delete tool)**로 전환하여 약 20개의 파일을 삭제했습니다. .cursor/와 .termaxa/ 폴더가 사라졌습니다.
해당 삭제 작업에 대한 감사(audit) 기록은 0건이었습니다. 무언가를 회피했기 때문이 아니라, 셸 후크(shell hook)가 이를 전혀 감지하지 못했기 때문입니다. 에이전트의 내장 파일 도구는 셸을 거치지 않습니다. 그것들은 탐지 범위(scope)에 아예 포함되지 않았던 것입니다.
저는 이 사실을 숨길 수도 있었습니다. 하지만 대신 이를 SECURITY.md의 첫 번째 항목으로 등록하고 공개 이슈(public issue)로 접수했으며, README의 부제목에도 명시했습니다: Termaxa는 앞유리(windshield)이지, 샌드박스(sandbox)가 아닙니다. Termaxa는 에이전트가 통상적으로 사용하는 셸 경로를 통제할 뿐, 다른 수단을 통해 실행되는 에이전트를 가두지는 못합니다. 강력한 보장을 원한다면 컨테이너(containers), seccomp, 제한된 자격 증명(restricted credentials)과 같은 OS 수준의 격리(isolation)와 병행하여 사용하십시오. 협력적 게이트(cooperative gate)는 유능한 에이전트가 값비싼 실수를 저지르려는 일반적인 상황을 방어합니다. 적대적인 에이전트를 격리하는 용도는 아닙니다. (진정한 해결책은 타인의 실행 경로를 후킹하는 것이 아니라 실행 경로 자체를 소유하는 것입니다. 그것이 현재의 모습이 아닌 향후 로드맵입니다.)
동일한 세션에서 발견한 또 다른 사실은 규모는 작지만 실재하는 것이었습니다. 바로 세션 ID(session id)당 차단 횟수(breaker counts)에 관한 것인데, Cursor와 Claude Code 모두 때때로 재시작 없이 **실행 도중 세션 ID를 교체(rotate the session id)**합니다. 시도 사이에 교체가 일어나면 카운터가 초기화됩니다. 실제로 한 번에 몰아서 재시도(burst of retries)를 하면 하나의 ID를 공유하게 되며 — 위에서 발생한 트립(trip)은 교체 없이 한 번에 몰아서 발생한 사례였습니다 — 이는 차단 기능(breaker)을 지속적인 제한(durable cap)이 아닌 일시적인 과속 방지턱(speed bump)으로 만듭니다. 이 내용 또한 보고 및 문서화되었습니다.
라운드 4: 나를 겁먹게 만든 것
이것은 제가 단 하나의 발견만을 남길 수 있다면 가장 먼저 내세울 내용입니다.
몇 주 후, 실행 후 영수증(post-execution receipts) 기능을 연결하던 중, 디버그 캡처가 활성화된 상태(TERMAXA_HOOK_DEBUG, 후킹이 받는 모든 로우 페이로드(raw payload)를 덤프함)로 Cursor에 대한 일상적인 라이브 테스트를 수행했습니다. 캡처 결과는 **6번의 후킹 호출(hook invocations)**을 보여주었습니다. 하지만 감사 로그(audit log)에는 항목이 0개였습니다.
근본 원인: Cursor 3.11에서 후킹 API(hook API)의 이름을 변경했습니다. 이벤트는 tool_name: "Shell"과 함께 preToolUse/postToolUse (카멜 케이스, camelCase) 형태로 도착했습니다. 제 파서(parser)는 tool_name: "Bash"를 사용하는 이전 방식인 beforeShellExecution/afterShellExecution 형태만 알고 있었습니다. 현재 버전의 Cursor에서 오는 모든 이벤트는 파서를 통과하지 못하고 None을 반환했습니다. 이는 설계상 "우리 것이 아니니 비켜라"라는 의미입니다.
Termaxa는 배관(plumbing) 문제에 대해 의도적으로 '페일 오픈(fail-open, 오류 시 개방)' 방식을 취합니다. 혼란을 겪을 때 에이전트를 먹통으로 만드는 게이트(gate)는 결국 삭제될 것이고, 그러면 아무도 보호할 수 없게 되기 때문입니다. 하지만 페일 오픈에는 그림자가 있으며, 이것이 바로 그 그림자였습니다. Termaxa는 약 4개의 마이너 버전 동안 Cursor 3.11+를 조용히 차단하지 못하고 있었습니다. 오류도, 충돌도, 신호도 없었습니다. 도구는 설치되어 있고 정상 작동하는 것처럼 보였지만, 실제로는 아무것도 하지 않고 있었습니다.
여기서 일반화할 수 있는 부분은 다음과 같습니다: 전체 테스트 스위트(test suite)는 내내 통과(green) 상태였습니다. Cursor 테스트는 이전 페이로드 형태를 피스처(fixtures)로 사용했습니다. 테스트는 Cursor가 더 이상 사용하지 않는 방언(dialect)을 Termaxa가 잘 처리하는지를 충실히 검증하고 있었던 것입니다. 통합 접점(integration surface)에서 테스트 스위트가 통과(green)되는 것은 필요조건일 뿐 충분조건은 아닙니다. 이러한 종류의 드리프트(drift, 괴리)를 잡아낼 수 있는 유일한 방법은 실제 에이전트를 실행하고 그것이 실제로 무엇을 보내는지 확인하는 것뿐입니다.
해결책 (v0.11.4): 두 API 세대 모두에 걸친 대소문자 구분 없는 이벤트 매칭 (case-insensitive event matching), 여러 신호를 통한 Cursor 탐지, 여러 페이로드 (payload) 위치로부터의 명령어 및 현재 작업 디렉토리 (cwd) 복구 — 그리고 결정적으로, 피스처 (fixtures)가 실제로 캡처된 3.11 페이로드인 4개의 회귀 테스트 (regression tests)를 도입했습니다. 이를 통해 다음번의 조용한 이름 변경이 사용자에게 실패를 안기는 대신 CI (지속적 통합) 단계에서 실패하도록 했습니다. Cursor 3.11.25에서 실시간 검증을 완료했습니다: 훅 (hook) 엔트리와 사후 영수증 (post receipts)이 모두 다시 정상적으로 흐르고 있습니다.
실제로 일반화되는 것들
만약 여러분이 AI 에이전트와 실제 인프라 (infrastructure) 사이에서 작동하는 무언가를 구축하고 있다면, 다음의 네 가지 교훈을 한데 모아 정리해 드립니다:
철자가 아닌 의도를 분류하세요. 목표가 차단된 에이전트는 셸 (shell)을 넘나들거나, 간접적인 방식(indirection)을 사용하거나, 정책의 어휘가 바닥나는 곳 어디에서든 다른 구문으로 목표를 재시도합니다. 패턴 목록은 구조적으로 이러한 경쟁에서 패배합니다. 의도 분류 (Intent classification)야말로 하나의 수정 사항으로 전체 카테고리를 해결할 수 있게 해준 핵심입니다.
에스컬레이션(escalate)만 하세요; 절대 완화하지 마세요. 명시적인 인간의 allow (허용)를 의심하는 안전 계층은 사람들이 제거해 버리는 안전 계층이 됩니다. 자동 승인이 조용히 allow로 침식되는 중간 단계인 ask (질문) 단계를 강화하고, 의도된 정책은 그대로 두십시오.
그린 테스트 (Green tests)는 통합 표면에 대해 거짓말을 합니다. 여러분의 피스처 (fixtures)는 어제의 API를 인코딩하고 있습니다. 실제 에이전트를 대상으로 실전 테스트를 수행하고, 실제 페이로드를 캡처하여, 그것들을 여러분의 피스처로 만드세요.
누군가 묻기 전에, README에 도구가 멈추는 지점을 명시하세요. 네이티브 도구 탈출 (native-tool escape) 기능은 만약 다른 누군가가 이를 발견했다면 HN (Hacker News)에서 파괴적인 댓글을 불러왔을 것입니다. 제 SECURITY.md의 첫 번째 줄에 이 내용을 적어둔 것이 바로 문서의 나머지 부분을 신뢰할 수 있는 이유입니다.
가장 큰 놀라움은 Cursor가 버그를 찾아냈다는 것이 아니었습니다. 모든 중대한 개선은 실제 에이전트가 내 테스트의 예측과 다르게 행동하는 것을 관찰하는 데서 나왔다는 점입니다. 만약 여러분이 AI 에이전트를 위한 인프라를 구축하고 있다면, 그것이 아마도 실제 개발 루프 (development loop)일 것입니다: 기능을 작성하고, 실제 모델을 그 기능에 적용하고, 모델이 여러분을 놀라게 하도록 내버려 둔 다음, 그 놀라움을 내일의 회귀 테스트로 전환하는 것입니다.
Termaxa는 MIT/Apache 라이선스의 오픈 소스(open source)이며, cargo install termaxa로 설치할 수 있습니다. 만약 제가 문서화하지 않은 방식으로 에이전트가 이를 통과하게 만들 수 있다면, 그것이 여러분이 할 수 있는 가장 가치 있는 기여입니다: issues 또는 security@termaxa.com.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기