6개의 호스팅 샌드박스들이 '격리됨(isolated)'이라고 말하지만, 어느 벽 뒤에서 에이전트가 실행되었는지 알려주지 않는다
요약
본 글은 에이전트 코드를 실행하는 호스팅 샌드박스 서비스들의 격리 수준(isolation)에 대한 실제 구현 상태를 분석합니다. plimsoll과 같은 도구는 실제로 얻은 격리 티어와 최소 요구 사항을 명시적으로 반환하여 보안성을 높입니다. 하지만 주요 클라우드 제공업체들은 실행된 환경의 정확한 경계나 격리 수준을 호출자에게 알려주지 않는다는 문제점을 지적합니다.
핵심 포인트
- plimsoll은 실제 얻은 격리 티어(process, container, kernel, vm)를 반환하여 보안성을 높임.
- 대부분의 호스팅 샌드박스 서비스는 실제로 실행된 '벽'(isolation boundary)을 명확히 알려주지 않음.
- 최소 요구되는 격리 수준 미달 시 정확한 오류 코드를 제공하는 것이 중요함.
- 오류가 발생했을 때, 단순히 에러 코드만으로는 '실행되지 않았는지'를 확실히 구분하기 어려움.
어제 plimsoll에 대한 Show HN 글이 올라왔습니다. plimsoll은 사용자가 직접 실행하는 Go 데몬으로, 에이전트가 작성한 코드를 실행합니다. 샌드박스 부분은 평범합니다. 흥미로운 부분은 모든 결과에 실제로 얻은 격리 수준(isolation tier)을 값으로 담아 전달하며, 요청 시 무언가가 실행되기 전에 최소 요구되는 티어를 지정할 수 있다는 점입니다.
이 두 번째 부분이 핵심입니다. plimsoll의 자체 문서에는 2026년 9월에 여섯 개의 호스팅 샌드박스 서비스(E2B, Modal, Daytona, Vercel Sandbox, Cloudflare, Northflank)의 현재 문서를 검토했으며, 이들 중 어느 것도 실행이 실제로 얻은 벽(wall)을 반환하지 않으며, 호출자가 지정한 최소 수준보다 낮은 실행을 거부하는 곳은 없다는 내용이 담겨 있습니다.
저는 주장을 믿기보다는 그중 두 곳을 직접 확인해 보았습니다.
Cloudflare의 샌드박스 문서는 벽에 대해 명확하게 설명합니다: "각 인스턴스는 자체 커널과 네트워크를 가진 마이크로VM이므로, 다른 워크로드와 공유되지 않습니다." 이는 경계(boundary)에 대한 실제 진술이며, 제품에 대한 진술이기도 합니다. 실행 결과 중 어느 것도 호출당 이 내용을 반복해서 알려주지 않으며, 요청별 최소 기준을 설정하는 기능도 없습니다.
Modal은 다른 관점에서 접근합니다. Modal의 Sandbox.create 참조는 experimental_options를 받는데, 여기에 {"vm_runtime": True}를 전달하면 기본 gVisor 레이어 대신 전체 가상 머신(full virtual machine)을 요청하게 됩니다. 반환되는 내용 중 어느 것도 특정 호출이 두 서비스 중 어디에서 처리되었는지 알려주지 않습니다. 사용자가 요청했지만, 어떤 것을 얻었는지는 알려주지 않습니다.
결과 필드로 제공되는 네 가지 티어
plimsoll의 티어는 process, container, kernel, vm 순서로, 가장 약한 것부터 나열됩니다. 이 티어는 데이터시트의 문장이 아니라 모든 결과에 포함된 값입니다. 요청은 MinimumIsolation을 설정하며, 데몬이 이를 충족할 수 없으면 ErrInsufficientIsolation 오류를 받고 아무것도 실행되지 않습니다.
마지막 조항이 제가 티어 이름보다 더 신경 쓰는 부분입니다. '아무것도 실행되지 않았다(Nothing ran)'와 '실행했을 수도 있다(may have run)'는 다른 사실이며, 단순한 에러 코드는 이 둘을 구별할 수 없습니다. InvalidArgument는 보통 유효성 검사에 실패한 요청이지만, 워커가 컨테이너를 시작한 후 거부할 수도 있습니다. 용량 부족 거부는 데몬의 리미터(limiter)에서 올 수도 있고 제공자(provider)로부터 올 수도 있습니다. 따라서 디스패치(dispatch) 전에 발생한 거부는 이유(request, permission, protocol, unsupported, isolation, environment, capacity)와 함께 표시되며, 마크가 없는 거부는 '실행했을 수도 있다'로 해석됩니다. 이것이 보수적인 기본값입니다: 오류를 표시하는 것을 잊은 코드 경로는 안전한 재시도(retry)로 절대 오인될 수 없습니다.
이것 아래에 놓인 쉽게 놓칠 수 있는 세부 사항이 있습니다. 이 표시는 응답이 요청 ID를 반환할 때만 유효합니다. 그렇지 않으면 하나의 요청에 대한 답변을 다른 요청의 것으로 제공하는 혼란스러운 프록시(proxy)가 호출을 하면 '아무것도 실행되지 않았다'로 읽히게 되고, 재시도 시에는 두 번 실행됩니다. 클라이언트는 각 요청마다 새로운 32자리 16진수 ID를 보내고 데몬이 이를 반향합니다.
기록은 다른 사람에게 건넬 수 있는 부분입니다
답변된 모든 실행(run)은 전송된 요청의 SHA-256 다이제스트와 반환된 결과의 다이제스트, 그리고 제공자, 티어, 이미지 식별자 및 타이밍을 담고 있습니다. 데몬은 의도적으로 서명 키를 보유하지 않습니다. 왜냐하면 악성 코드를 실행하기 때문에 엔진 탈출(engine escape) 한 번만으로 모든 기록을 위조할 수 있는 열쇠가 될 수 있기 때문입니다. 데몬 외부의 하네스(harness)가 자체 복사본으로부터 다이제스트를 재계산하고 일치하는 것을 서명합니다. 결정론적 워크로드(deterministic workload)의 경우 저장된 요청을 다시 실행하여 다이제스트를 비교할 수 있습니다.
그들의 cart-pole 예제 실행 보고서는 그것이 무엇을 사주는지 보여줍니다. 에이전트가 작성한 컨트롤러를 두 번 실행했을 때, 동일한 지문(fingerprint) 63cfd676924a3896127d60c97333677f3b3c804dd764dba001121f6c234a937f이 나옵니다. 에이전트의 첫 번째 초안은 구조는 같지만 게인(gains) 두 개가 부호가 틀렸고, 지문도 다르며, 5.96초에 폴이 아래로 내려갑니다. 이 동일한 실행을 다섯 개의 다른 제공업체에서 수행했는데, 그중 네 개는 백엔드가 달랐음에도 불구하고 같은 값으로 해시되었습니다.
해당 페이지는 또한 자신이 보여주지 않는 것을 명확하게 말합니다. 컨트롤러는 플랜트(plant)와 러너(runner)를 읽을 수 있는데, 이들은 동일한 읽기 전용 루트에 있기 때문입니다. 지문은 점수가 아니라 신원이며, 플랜트를 재컴파일하면 페이지의 모든 지문이 이동합니다.
자격 증명(credential) 이야기가 실질적인 부분입니다
제가 에이전트 샌드박스에 대해 읽은 것 중 대부분은 격리(containment)에서 끝납니다. capability grants doc는 팀에게 실제로 문제가 되는 부분, 즉 에이전트의 코드가 사용자의 API를 호출해야 한다는 점을 더 깊이 다룹니다. 그리고 일반적인 해결책은 샌드박스에 토큰(token)을 전달하는 것입니다.
여기서 부여되는 권한(grant)은 한 번의 실행이 특정 API의 나열된 경로들을 호출할 수 있는 허가입니다. 권한 없이는 해당 실행은 네트워크 연결 자체가 없습니다. Docker 실행은 --network none을 유지하고, 호출은 실행별 유닉스 소켓(Unix socket)을 통해 전송됩니다. 코드는 plimsoll이 샌드박스 안에 배치하는 작은 클라이언트(client)를 호출하고, 외부의 브로커(broker)가 실제 호출을 수행하며, 호출자(caller)와 정확한 경로를 확인한 다음, 실행별로 발행된 자격 증명(credential)을 첨부합니다. 토큰은 절대 샌드박스 안으로 들어가지 않으며, 클라이언트를 건너뛰고 수동으로 외부를 호출하는 코드는 브로커가 규칙을 강제하기 때문에 아무 데도 도달할 수 없습니다.
경로 매칭(route matching)은 그들이 명백히 취약점을 보인 부분입니다. 경로가 승인된 경로와 가장 단순한 형태에서 바이트 단위로 동일하게 디코딩되었을 때만 허용됩니다. 쿼리, .. 탐색(traversal), 그리고 퍼센트 인코딩 트릭은 어떤 것이 전송되기 전에 거부됩니다. 또한 Tomcat과 Spring이 /orgs/..;/repos/x를 /repos/x로 읽기 때문에 경로 세그먼트 내부의 ; 파라미터도 거부됩니다. *는 예약되지 않은 문자(unreserved characters) 한 세그먼트에만 일치합니다. Google의 커스텀 메서드 규칙을 따르는 API에서, POST /v1/items/a:setIamPolicy는 POST /v1/items/a와 다른 작업이며, OData에서는 POST /odata/$batch가 다른 요청들의 배치(batch)를 담고 있기 때문에, POST /v1/items/*를 허용한다고 해서 이 두 경우 모두에 적용되지 않습니다.
제가 예상했던 것보다 더 마음에 들었던 작은 부분이 있습니다. 세 가지 항목이 동일한 경로 집합(브로커가 강제하는 허용 목록, 에이전트가 호출하는 JavaScript 클라이언트, 모델이 읽는 도구 설명)을 설명하지만, 인간이 이들을 동기화하려고 할 때마다 서로 어긋납니다. plimsoll-specgen은 하나의 OpenAPI 문서를 통해 이 세 가지를 모두 생성하며, 모호한 입력에 대해서는 닫힌 실패(fails closed)로 처리하고, 표현할 수 없는 것은 조용히 누락하는 대신 경고합니다. 또한 문서의 모든 작업을 허용으로 내보내는데, 이는 사양이 에이전트가 도달해야 할 것이 아니라 API가 가진 것을 설명하기 때문입니다. 이 내용을 그대로 붙여넣으면 전체 API를 허용하게 됩니다. 문서 자체가 그렇게 말하고 있습니다.
제가 이것에 대해 글을 쓰는 이유
문서에는 해당 계층(tier)이 구성과 시작 시의 스모크 테스트(smoke tests)이며, 실제로 어떤 소프트웨어가 실행되고 있는지에 대한 암호학적 증명인 검증(attestation)은 아니라고 나와 있습니다. 또한 이 프로젝트는 1.0 버전 이전이고, 외부 보안 감사를 거치지 않았으며, 핵심 인력(bus factor)이 한 명이라고 합니다. gVisor는 Linux 전용이라 Mac에서는 컨테이너 계층을 사용합니다. wasm 제공자는 데몬 자체 프로세스 내에서 실행되며, 문서에는 악성 코드를 연결하지 말라고 나와 있습니다. 이 문장들이 나머지 내용을 검증 가능하게 만드는 부분이며, 이것이 제가 다른 출시 후기보다 이 글을 더 읽고 싶게 만든 이유입니다.
내 생각에 이건 내가 해체했던 모든 정확도 주장들과 같은 형태다. '격리됨(isolated)'이라는 것은 '96% 정확함'과 같은 주장이다. 반증 가능한 버전은 결과 필드, 요청의 하한선, 그리고 박스 밖에 있는 누군가가 재계산할 수 있는 기록을 가지고 있다. 이것이 바로 패치 대 실행 격차가 발생하는 지점이기도 하다. 만약 리뷰가 diff(변경분)만 읽고 실제 실행 기록은 읽지 않는다면, 실행 기록이 신뢰해야 할 것이며, 오늘날의 대부분 옵션들은 문장 하나를 제공할 뿐이다.
실용적인 버전으로 말하자면, 나 역시 누구에게 내 말을 믿으라고 요구하는 것은 아니지만: 다음에 에이전트 코드가 어디서 실행될지 선택한다면, 실제 실행(real run)에서 나온 결과 객체 하나를 요청하라. 만약 그 결과가 해당 실행의 경계(boundary)를 명시하지 않는다면, 여전히 마케팅 문구를 읽고 있는 것이다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기