AI가 쓴 코드를 어디서 돌릴까: MXC(microsoft/mxc)의 정확한 계약·기본 거부 정책·OS별 격리 백엔드 심층 해부

한 줄 요약: 코딩 에이전트가 만든 스크립트, LLM이 생성한 분석 코드, 서드파티 플러그인. 이런 ‘믿을 수 없는 코드’를 내 앱 안에서 돌려야 할 때 Microsoft가 내놓은 답이 microsoft/mxc다. 하나의 JSON 정책(파일·네트워크·환경)을 받아 Linux는 Bubblewrap, Windows는 ProcessContainer, macOS는 Seatbelt로 집행하는 SDK다. 10월 7일 v1.0.0으로 안정판이 됐고 오늘 GitHub Trending Rust 일간 2위에 올랐다. 설계를 읽고, 박스에서 릴리스 바이너리로 정책 집행과 공식 테스트 스위트를 직접 돌려 봤다.

MXC는 모델 출력 같은 믿을 수 없는 코드에 파일·네트워크·환경 정책을 담은 JSON 하나를 붙여, Linux는 Bubblewrap, Windows는 ProcessContainer, macOS는 Seatbelt로 집행한다
MXC는 모델 출력 같은 믿을 수 없는 코드에 파일·네트워크·환경 정책을 담은 JSON 하나를 붙여, Linux는 Bubblewrap, Windows는 ProcessContainer, macOS는 Seatbelt로 집행한다

왜 지금 이 저장소인가

에이전트가 코드를 ‘제안’하던 시기는 지났다. 지금은 에이전트가 직접 실행한다. 문제는 실행 위치다. 개발자 노트북에서 그대로 돌리면 ~/.ssh, 클라우드 자격 증명, 브라우저 쿠키가 모두 사정권에 들어온다. 그래서 Codex CLI, Claude Code 같은 도구들은 저마다 샌드박스를 붙였고, 호스팅형 샌드박스 서비스도 생겼다. 하지만 ‘우리 앱’에 넣을 수 있는 범용·크로스 플랫폼 부품은 마땅치 않았다. MXC는 그 빈자리를 노린다. README의 첫 문장이 정확히 “모델 출력, 플러그인, 도구 같은 믿을 수 없는 코드를 Windows·Linux·macOS에서 실행하는 샌드박스 실행 시스템”이다.

항목 확인한 값 (10/11 KST 기준) 해석
저장소 microsoft/mxc — Microsoft eXecution Container (Rust, MIT) 모델 출력·플러그인·도구처럼 믿을 수 없는 코드를 Windows·Linux·macOS에서 격리 실행하는 SDK. 앱에 라이브러리로 넣는 형태다
별 / 포크 2,602 / 131 (17:04 KST, GitHub API) 오늘 GitHub Trending Rust 일간 2위(오늘 +306). 규모는 작지만 v1.0 직후라 관심이 몰리는 중
최신 릴리스 v1.0.0 (10/7 14:54 KST), 이전 v0.9.0(9/29)·v0.8.0(8/23) 첫 안정 SDK. Rust mxc_sdk::v1, .NET Microsoft.Mxc.Sdk.V1, Node @microsoft/mxc-sdk/v1가 1.x 호환 경계이고 이후 semver를 따른다고 밝혔다
배포 채널 crates.io mxc-sdk, NuGet Microsoft.Mxc.Sdk, npm @microsoft/mxc-sdk, 서명 바이너리 zip(약 536MB) npm 패키지(압축 해제 약 101MB)에 Linux·Windows·macOS 실행기와 libmxc_ffi.so가 x64·arm64별로 들어 있다
코드 규모 (v1.0.0 직접 집계) Rust 약 24만 6천 줄 — core/ 9만 7천, backends/ 9만 4천, 통합 테스트 2만 1천. Node SDK TS 1만 2천, .NET C# 1만 5천 ‘bwrap 래퍼’ 수준이 아니다. 무게가 계약 파싱·정규화(core)와 백엔드별 정책 집행에 고르게 실려 있다
이력 저장소 생성 2026-02-06, v1.0.0까지 커밋 473개, 작성자 29명 공개 이력은 2026-06-01부터 시작한다(그 전 이력은 정리된 것으로 보인다). 1인 프로젝트가 아니라 팀 단위 개발
빌드 요구 Rust 1.93(rust-toolchain.toml 고정), Node.js 24+, 백엔드별 OS 요구사항 Node SDK의 engines도 node >=24다. Node 20 환경에서는 SDK 대신 실행기 바이너리를 직접 써야 했다(아래)

이 글은 README, docs/development/architecture/repository-architecture.md, Bubblewrap 백엔드 문서(877줄), API 레퍼런스, 백엔드별 문서, v1.0.0 릴리스 노트, 그리고 박스에서 직접 실행한 결과를 바탕으로 썼다. Windows·macOS 백엔드는 실행해 보지 않았다. 그쪽에 관한 문장은 모두 프로젝트 문서의 설명이다.

구조: 서비스가 아니라 ‘앱 안에 들어가는 SDK’

MXC는 데몬도, 별도 컨테이너 런타임도 아니다. 앱이 SDK를 링크하고, SDK가 프로세스 안에서 요청을 검증하고 백엔드를 고른 뒤 격리된 작업을 띄운다. 앱이 정하는 건 세 가지뿐이다. 컨테이너 종류, 격리 규칙, 실행할 명령. 언어 SDK는 Rust 핵심 위에 얇게 얹혀 있다. Node와 .NET은 mxc_ffi라는 C ABI 라이브러리를 부르고, 둘 다 자기 요청을 SDK 소유의 ‘정확한 계약 JSON’으로 바꾼 뒤 Rust 쪽 파서에 넘긴다. 언어마다 파서를 따로 두지 않아 해석이 갈라질 여지를 줄였다.

경로 역할 눈여겨볼 점
src/mxc-sdk/src/core/ mxc_common(파싱·정규화·공통 모델), mxc_contract(버전별 정확한 계약), mxc_engine(백엔드 선택·디스패치), 텔레메트리, PTY 백엔드 중립 기반. 백엔드 모듈은 이 위에 crate:: 경로로 올라간다
src/mxc-sdk/src/backends/ bubblewrap, process_container, seatbelt, lxc, wslc, windows_sandbox, isolation_session, hyperlight, nanvix 백엔드별 검증·정책·실행. 별도 crate로 쪼개지 않고 한 패키지 안의 모듈로 둔다
src/mxc-sdk/src/bin/ windows_sandbox_daemon/guest, wslc_daemon 프로세스 경계가 꼭 필요한 백엔드만 같은 패키지의 바이너리 타깃으로 둔다
src/ffi/mxc_ffi C ABI 공유·정적 라이브러리 Node·.NET SDK가 이걸 부른다. 언어 SDK가 각자 파서를 갖지 않는다
src/tools/ wxc(Windows), lxc(Linux), mxc_darwin(macOS) 실행기, 스키마 생성기, 진단 콘솔 실행기는 JSON 요청을 받는 얇은 CLI. 테스트나 SDK를 넣기 어려운 환경용
sdk/node, sdk/dotnet TypeScript·C# SDK 생성된 와이어 타입(dist/generated/v1_0_0 등)을 버전별 디렉터리로 들고 있다
schemas/stable mxc-config.schema.0.4.0-alpha ~ 1.0.0.json 요청 JSON의 공개 계약. 편집기 검증용 $schema를 달 수 있다
tests/configs, tests/scripts 백엔드별 예제 요청과 호스트 의존 테스트 스크립트(run_bwrap_*.sh 등) 박스에서 이 스크립트를 릴리스 바이너리로 돌렸다
# 앱에 SDK로 넣기 (저장소를 받을 필요 없음)
cargo add mxc-sdk                      # Rust: use mxc_sdk::v1
dotnet add package Microsoft.Mxc.Sdk    # .NET: Microsoft.Mxc.Sdk.V1
npm install @microsoft/mxc-sdk          # Node 24+: import ... from '@microsoft/mxc-sdk/v1'

# Linux 준비물
sudo apt install bubblewrap                       # 기본 백엔드 (0.5.0 이상)
sudo apt install slirp4netns util-linux iptables  # CIDR 규칙·프록시 모드를 쓸 때만

Node SDK의 공개 예제는 이렇게 생겼다. 호출자는 스키마 버전이나 JSON을 직접 쓰지 않는다.

import { spawn, type ContainerRequest } from '@microsoft/mxc-sdk/v1';

const request: ContainerRequest = {
  command: 'node -e "console.log(\'hello from container\')"',
  network: { egress: { default: 'deny' } },
  timeoutMs: 30_000,
};
const child = await spawn(request);   // 라이브 stdin/stdout/stderr 핸들

실행 모양은 세 가지다. 끝날 때까지 기다려 출력을 모아 받는 run, 표준 입출력 파이프를 살아 있는 핸들로 받는 spawn, 크기 조절까지 되는 터미널을 받는 spawnWithPty. 여기에 컨테이너를 오래 유지하는 수명주기 API(provision → start → exec → stop → deprovision)가 따로 있고, 수명주기 연산마다 실제 수행 없이 네이티브 검증만 하는 validate* 짝이 붙는다. 상태 유지 수명주기는 백엔드마다 지원이 다르다. Bubblewrap은 create-and-run만 지원한다.

백엔드: 하나의 계약, OS마다 다른 엔진

백엔드 (containment 값) OS 격리 기술 v1.0 상태
bubblewrap Linux (기본) 사용자 네임스페이스 + bind mount. root 불필요 안정
lxc Linux LXC 컨테이너, 별도 rootfs 다운로드. root 필요 안정
seatbelt macOS 15+ (기본) JSON 정책을 Seatbelt 프로필로 번역해 fork()와 exec() 사이에 sandbox_init() 안정
processcontainer Windows 11 (기본) 프로세스 격리. 호스트가 지원하면 신형 BaseContainer(process security environment), 아니면 AppContainer로 내려감. WFP 송신 필터, 컨테이너별 WinHTTP 프록시 안정
isolation_session Windows 11 IsolationSession API 기반 프로세스 격리. 기존 컨테이너 안 PTY 실행은 이 백엔드만 지원 안정
wslc Windows WSL 컨테이너, 이미지·CPU·메모리·포트 매핑 지정 안정
windows_sandbox Windows Windows Sandbox VM 실험적
microvm (Nanvix) Windows(WHP)·Linux(KVM) 경량 VM. 문서상 콜드 스타트 약 100ms, 상주 메모리 약 100MB 실험적
hyperlight Linux(KVM)·Windows(WHP), x86_64 Hyperlight 마이크로 VM 위 Unikraft 유니커널. 웜 스냅숏에서 매번 복원. 스키마 1.1.0-alpha 실험적

표에서 볼 점은 ‘안정’과 ‘실험적’의 경계다. 하드웨어 가상화 계열(Windows Sandbox, Nanvix microvm, Hyperlight)은 모두 실험적이고(범용 vm 값은 v1.0.0 엔진에서 ‘지원하지 않음’으로 거부된다) --experimental 플래그가 있어야 돈다. 즉 v1.0에서 안정적으로 약속하는 경계는 대부분 OS의 프로세스 샌드박스다. 이 사실이 위협 모델을 정한다. 뒤에서 다시 다룬다.

핵심 설계 ①: ‘정확한 계약’과 버전별 어댑터

보안 도구에서 가장 위험한 버그는 ‘정책을 잘못 이해하고 조용히 느슨하게 돌리는 것’이다. MXC 요청 처리 파이프라인은 이걸 막는 쪽으로 짜여 있다.

계층 모듈 하는 일
1. 요청 JSON 또는 base64 JSON, 또는 SDK 타입 SDK를 쓰면 호출자는 JSON·스키마 버전을 직접 다루지 않는다. SDK가 정확한 계약 JSON으로 바꿔 넘긴다
2. 계약 선택 mxc_contract registry version 값에 정확히 맞는 등록 계약만 고른다. v1.0.0 실행기는 0.9.0-alpha, 1.0.0, 1.1.0-alpha만 받는다
3. 정규화 버전별 어댑터 → 비공개 CommonRequestIR → ExecutionRequest ‘굴러가는 하나의 통합 파서’를 없앴다. 버전마다 어댑터가 따로 있고 공통 정규화는 한 곳
4. 백엔드 선택 mxc_engine, backend_registry.rs 등록 메타데이터(실험적 여부 포함)로 실행 권한을 판단. --experimental 없이 실험 백엔드는 못 쓴다
5. 검증·집행 각 백엔드 모듈 집행할 수 없는 정책은 프로비저닝 전에 거부. 조용히 느슨하게 돌리지 않는다
6. 실행 표면 run-to-completion / streaming / PTY / 상태 유지 수명주기 run·spawn·spawnWithPty, 그리고 provision→start→exec→stop→deprovision
요청은 버전에 정확히 맞는 계약을 고른 뒤 정규화와 백엔드 선택을 거친다. 옛 계약 버전, 모르는 필드, 호스트명 CIDR, 집행할 수 없는 ingress 허용은 실행 전에 거부됐다
요청은 버전에 정확히 맞는 계약을 고른 뒤 정규화와 백엔드 선택을 거친다. 옛 계약 버전, 모르는 필드, 호스트명 CIDR, 집행할 수 없는 ingress 허용은 실행 전에 거부됐다

아키텍처 문서는 “굴러가는 하나의 통합 파서나 모델은 더 이상 없다”고 적는다. 요청의 version에 정확히 맞는 계약을 고르고, 버전별 어댑터가 비공개 중간 표현으로 바꾼 뒤 공통 정규화를 거친다. 옛 필드를 새 버전에 섞어 넣거나, 버전 숫자만 올려서 옛 정책을 들고 오는 건 허용되지 않는다. 문서 표현으로는 “옛 설정의 버전만 바꾼다고 정책이 이전되지 않는다.” 실제로 박스에서 0.8.0-alpha 요청은 등록된 버전 목록과 함께 거부됐고, 최상위에 bogusField 하나를 넣은 요청은 허용 필드 목록과 정확한 위치를 알려 주며 거부됐다. 모르는 필드를 무시하고 진행하는 관대한 파서와 정반대다.

두 번째 원칙은 집행할 수 없으면 거부다. Bubblewrap은 slirp 기반 네트워크에 포트 포워딩이 없어서 ‘들어오는 연결 허용’을 실현할 수 없다. 그래서 ingress.default: "allow"를 받으면 이유를 길게 설명하며 시작조차 하지 않는다. 네트워크 도구(slirp4netns)가 없는 호스트에서 egress.default: "allow"를 요청해도 마찬가지로 거부한다. “호스트 네트워크 네임스페이스를 공유하거나 규칙 없이 돌리는 쪽으로 절대 대체하지 않는다”는 것이 문서의 약속이고, 실험에서도 그대로였다.

세 번째는 호스트명은 정책이 될 수 없다는 선택이다. CIDR 규칙에 api.openai.com을 넣으면 검증 단계에서 거부된다. 백엔드가 이름을 대신 풀어 IP 규칙을 만들면, 샌드박스 안의 프로그램이 받은 DNS 응답과 규칙의 IP가 어긋나는 순간 허용하지 않은 주소가 열릴 수 있기 때문이다. 이름 기반 정책이 필요하면 외부 프록시를 붙이고, 이름 판단은 그 프록시에 맡기라는 것이 설계다.

핵심 설계 ②: 기본 거부 파일시스템과 빈 환경

Linux 기본 백엔드인 Bubblewrap은 사용자 네임스페이스로 root 없이 격리한다. MXC가 얹은 가치는 ‘무엇을 보여 줄지’의 기준선이다. 동적 링커, libc, 시스템 도구, /etc만 읽기 전용으로 보여 주고 나머지는 처음부터 없다. $HOME, /root, /opt, /var, /sys, /usr/local까지 전부다. macOS Seatbelt 백엔드의 (deny default) 자세와 맞춘 것이라고 문서는 설명한다.

{
  "version": "1.0.0",
  "containment": "bubblewrap",
  "process": { "commandLine": "python3 /srv/data/job.py", "timeout": 5000 },
  "filesystem": {
    "readonlyPaths":  ["/srv/data"],
    "readwritePaths": ["/tmp/agent-work"],
    "deniedPaths":    ["/srv/data/secrets"]
  },
  "network": { "egress": { "default": "deny" } }
}

이 요청에서 실행기가 실제로 만든 bwrap 인자를 캡처했다. 인자는 모두 88개였고, 정책이 어떻게 마운트로 바뀌는지 그대로 보인다.

# 위 요청에서 lxc-exec v1.0.0이 실제로 만든 bwrap 인자 (PATH 앞에 기록용 래퍼를 둬서 캡처, 일부 생략)
--unshare-user --unshare-pid --unshare-ipc --unshare-uts --die-with-parent
--unshare-net
--ro-bind-try /bin /bin   --ro-bind-try /usr/lib /usr/lib   --ro-bind-try /etc /etc  ...
--ro-bind-try /run/systemd/resolve /run/systemd/resolve   --symlink /run /var/run
--dev /dev --proc /proc --tmpfs /tmp
--bind    /tmp/agent-work /tmp/agent-work
--ro-bind /srv/data /srv/data
--tmpfs   /srv/data/secrets
--clearenv --setenv PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
--setenv TERM xterm-256color
-- sh -c python3 /srv/data/job.py
Bubblewrap 백엔드는 시스템 경로만 읽기 전용으로 보여 주고 홈 디렉터리 등 나머지는 처음부터 숨긴다. 쓰기 허용 경로는 bind, 거부 디렉터리는 빈 tmpfs, 거부 파일은 /dev/null로 덮는다
Bubblewrap 백엔드는 시스템 경로만 읽기 전용으로 보여 주고 홈 디렉터리 등 나머지는 처음부터 숨긴다. 쓰기 허용 경로는 bind, 거부 디렉터리는 빈 tmpfs, 거부 파일은 /dev/null로 덮는다
정책 필드 bwrap 매핑 (실측 argv) 박스 실험 결과
(기준선) --ro-bind-try로 /bin /sbin /lib* /usr/bin /usr/sbin /usr/lib* /usr/libexec /usr/share /etc + DNS용 /run/... /home·/workspace 자체가 존재하지 않음. 홈에 둔 카나리 파일 읽기 실패. /usr/bin 쓰기 차단
readonlyPaths --ro-bind 경로 경로 읽기 성공, 쓰기는 Read-only file system. 호스트에 파일 안 생김
readwritePaths --bind 경로 경로 샌드박스가 쓴 파일이 호스트에 그대로 보임
deniedPaths (디렉터리) --tmpfs 경로 빈 디렉터리(항목 0개)로 보임
deniedPaths (파일) --ro-bind /dev/null 경로 crw-rw-rw- 1, 3(= /dev/null)로 바뀌고 열기 시 Permission denied. 원래 내용 노출 없음
정책 경로의 부모 bwrap이 마운트 지점용 골격 디렉터리 생성 /workspace/…/host 아래에 정책에 넣은 이름만 보이고, 넣지 않은 형제 디렉터리는 안 보임
/tmp, /dev, /proc --tmpfs /tmp, --dev /dev, --proc /proc /tmp는 빈 tmpfs. PID 1은 bwrap(PID 네임스페이스 분리)
환경 변수 --clearenv + --setenv PATH …, --setenv TERM xterm-256color 호스트에 export한 비밀 변수가 안 넘어옴. 보이는 건 PATH·TERM·PWD뿐

세부에서 공들인 흔적이 보인다. 거부 대상이 파일이면 tmpfs 대신 /dev/null을 덮는다. tmpfs를 쓰면 파일이 빈 디렉터리로 바뀌어 종류가 달라지기 때문이다. 거부 경로에 심볼릭 링크가 끼어 있으면 bwrap이 마운트 지점을 못 만들고 죽기 때문에, 링크를 끝까지 따라가 실제 경로를 가린다. 공식 스위트의 Denied Masking·Object Validation 테스트가 바로 이 우회(링크로 거부 경로를 피하기)를 검사한다. 환경 변수는 --clearenv로 비우고 PATH·TERM만 넣는다. 실험에서 호스트에 export한 비밀 변수는 샌드박스에 나타나지 않았다.

함정도 문서가 직접 짚는다. process.cwd를 지정하면 그 디렉터리가 HOME이 된다. 믿을 수 없는 저장소를 작업 디렉터리로 주면 그 안의 .gitconfig, .npmrc, .curlrc가 ‘사용자 설정’으로 읽힌다. Git의 core.fsmonitor나 npm 설정처럼 설정 파일이 곧 실행 경로가 되는 도구가 많으니, 이 경우엔 HOME을 따로 지정하는 게 맞다.

핵심 설계 ③: 루트 없는 네트워크 정책

네트워크는 세 갈래다. 규칙 없는 전면 차단은 --unshare-net 하나로 끝난다. 루프백만 있는 빈 네트워크 스택이라 추가 도구가 필요 없다. 네트워크 섹션을 아예 빼도 이 모드가 된다. 기본값이 ‘차단’이라는 뜻이다.

IP·포트 단위 규칙이 필요하면 구조가 복잡해진다. slirp4netns로 샌드박스 전용 네트워크 네임스페이스를 만들고, 그 네임스페이스 안에서만 CAP_NET_ADMIN을 가진 감독 프로세스가 iptables 규칙을 설치한다. 작업 프로세스는 시작 전에 권한을 모두 버리므로 규칙을 되돌릴 수 없다. 호스트 root 권한은 어디에도 필요 없다. 거부 규칙은 허용 규칙보다 앞에 설치되는 first-match 체인이라, 넓은 허용 안의 좁은 거부가 이긴다. 들어오는 방향에는 MXC_INGRESS 체인이 걸린다. 문서는 이것이 “새로운 보호가 아니라 심층 방어”라고 솔직하게 적는다. 포트 포워딩이 없으니 애초에 들어올 길이 없고, 체인은 미래의 변경에 대비한 것이라는 설명이다.

규칙 없는 차단은 --unshare-net, IP·포트 규칙은 slirp4netns와 네임스페이스 안 iptables, 프록시 모드는 프록시 엔드포인트만 연다. 세 경우 모두 들어오는 연결은 막힌다
규칙 없는 차단은 –unshare-net, IP·포트 규칙은 slirp4netns와 네임스페이스 안 iptables, 프록시 모드는 프록시 엔드포인트만 연다. 세 경우 모두 들어오는 연결은 막힌다
네트워크 모드 구현 필요 도구 박스 실험
생략 또는 egress.default: deny (규칙 없음) --unshare-net. 루프백만 있는 빈 네트워크 스택 bwrap만 호스트에서 열리는 1.1.1.1:443·호스트 127.0.0.1:18777 모두 차단. 인터페이스는 lo 하나, netns ID가 호스트와 다름
CIDR 허용/거부 규칙 slirp4netns로 사설 netns, 그 안에서 CAP_NET_ADMIN을 가진 감독 프로세스가 iptables 규칙 설치 후 작업 프로세스는 권한을 버림 slirp4netns, util-linux unshare·nsenter, iptables·ip6tables(nf_tables), nf_conntrack 1.1.1.1/32 tcp 443만 허용 → 1.1.1.1:443 열림, 1.0.0.1:443·8.8.8.8:53·호스트 게이트웨이 차단. CapEff=0
egress.default: allow + 거부 규칙 같은 사설 netns, 체인 기본 ACCEPT. 호스트 루프백은 먼저 DROP 위와 같음 1.0.0.0/24 거부 → 1.0.0.1 차단, 1.1.1.1·8.8.8.8 열림, 10.0.2.2(호스트) 차단
runtimeConfig.networkProxy HTTP(S)_PROXY 주입 + 프록시 엔드포인트만 허용하는 DROP 체인. DNS도 닫음 위와 같음 공식 스위트의 Network Proxy 테스트로 확인(아래)
ingress.default: allow — — 실행 전 거부. slirp에 포트 포워딩이 없어 약속을 지킬 수 없기 때문

박스에서는 slirp4netns와 iptables(nf_tables)를 설치한 뒤 두 정책을 돌렸다. 1.1.1.1/32의 TCP 443만 허용하자 정확히 그 목적지만 열렸고, 같은 Cloudflare의 1.0.0.1, Google DNS, 호스트 게이트웨이(10.0.2.2)는 막혔다. 기본 허용에 1.0.0.0/24 거부를 얹으면 그 대역만 막히고 호스트 게이트웨이는 여전히 막혔다. 두 경우 모두 샌드박스 안의 CapEff는 0이었다. 운영에서 알아둘 점은 차단 방식이 REJECT가 아니라 DROP이라는 것이다. 막힌 연결은 즉시 실패하지 않고 클라이언트 타임아웃까지 매달린다. 테스트 스크립트에 5초 타임아웃을 줬더니 규칙 모드 실행 한 번이 15초 안팎 걸렸다.

설치와 실행: SDK 대신 실행기로 직접

박스의 Node는 20.19였고 SDK는 Node 24 이상을 요구한다. 게다가 npm install은 의존성 node-pty의 네이티브 빌드에서 실패했다(Linux x64 prebuild가 없어 node-gyp로 넘어갔는데 make가 없었다). 그래서 --ignore-scripts로 패키지만 받고, 안에 든 Linux 실행기 lxc-exec(약 12MB)를 직접 썼다. README가 ‘SDK를 넣을 수 없을 때나 테스트용’으로 안내하는 바로 그 경로다.

# Node SDK 없이: npm 패키지에 든 실행기 바이너리를 직접 사용
B=node_modules/@microsoft/mxc-sdk/bin/x64
$B/lxc-exec --available-backends          # 호스트에서 쓸 수 있는 백엔드·경고
$B/lxc-exec --dry-run request.json        # 파싱·검증만 ("validation passed")
$B/lxc-exec request.json                  # 실행 (stdout/stderr는 작업 프로세스 것)
$B/lxc-exec --debug request.json          # "spawning bwrap with 88 args" 같은 진단

# 결과 예 (2초 타임아웃 + sleep 30)
# start
# {"error":{"code":"backend_error","message":"script timed out after 2000ms"}}   exit=255

기본 요청은 약 40ms에 끝났다. 컨테이너 생성·삭제가 없는 단일 bwrap 실행이라 가볍다. 샌드박스 안의 uid는 호스트 사용자 그대로(1000)이고, /etc/os-release도 호스트 것이었다. 별도 rootfs가 없다는 뜻이다. 2초 타임아웃에 sleep 30을 넣은 요청은 2.005초에 끊겼고, 남은 프로세스는 없었다.

점검 (박스: Debian 13, 커널 6.12, bwrap 0.12.0, 비root, 10/11 KST) 결과 메모
npm install @microsoft/mxc-sdk@1.0.0 실패 → --ignore-scripts로 설치 의존성 node-pty의 linux-x64 prebuild가 없어 node-gyp 빌드로 넘어갔는데 make가 없었다. 게다가 SDK는 Node 24+ 요구(박스는 20.19)
lxc-exec --available-backends bubblewrap 1개, 경고: slirp4netns 없음 slirp4netns·iptables 설치 후엔 capabilities: ["proxyEnforcement"]
기본 요청(bubblewrap_basic.json) 종료 0, 실측 약 40ms uid는 호스트 사용자 그대로(1000), OS 정보는 호스트 것(별도 rootfs 없음)
파일시스템·환경·권한 (표 5) 12개 항목 전부 기대대로 ro 쓰기 차단, rw 반영, 거부 디렉터리 빈 tmpfs, 거부 파일 /dev/null, 홈 안 보임, CapEff·CapBnd 0
네트워크 (표 6) 차단·허용·거부 우선 모두 기대대로 차단된 연결은 DROP이라 클라이언트가 타임아웃(5초)까지 기다린다. 규칙 모드 실행은 15초 안팎
timeout: 2000 + sleep 30 2.005초에 종료, 종료 코드 255 script timed out after 2000ms. 남은 sleep 30 프로세스 없음
옛 계약 0.8.0-alpha 거부(종료 1) Unsupported contract version. Registered versions are: 0.9.0-alpha, 1.0.0, 1.1.0-alpha
모르는 최상위 필드 bogusField 거부(종료 1) 허용 필드 목록과 위치(line 1 column 162)를 함께 알려 준다
cidr에 호스트명(api.openai.com) --dry-run에서 거부 DNS를 대신 풀어 주지 않는다는 설계대로
egress.default: allow, slirp4netns 없음 실행 전 거부(종료 255) 열린 네트워크로 ‘조용히 대체’하지 않는다. 다만 메시지가 폐기된 필드명 network.enforcementMode를 언급한다

공식 테스트 스위트를 릴리스 바이너리로

저장소의 tests/scripts/run_bwrap_*.sh는 빌드 산출물 경로에서 lxc-exec를 찾는다. Rust 전체 빌드 대신 v1.0.0 태그를 체크아웃하고 npm 패키지의 릴리스 바이너리를 그 자리에 놓았다. 첫 실행은 14 통과·2 실패·1 건너뜀이었는데, 실패 두 건은 테스트용 프록시 도우미(unix-test-proxy)를 빌드하지 않아서였다. 그 도우미만 박스의 Rust 1.85로 빌드해 다시 돌리자 전부 통과했다.

git clone https://github.com/microsoft/mxc && cd mxc && git checkout v1.0.0
mkdir -p src/target/release
cp <npm 패키지>/bin/x64/lxc-exec src/target/release/      # 릴리스 바이너리로 테스트
(cd src && cargo build --release -p unix_test_proxy)       # 프록시 테스트 도우미
PATH=$PATH:/usr/sbin bash tests/scripts/run_bwrap_all_tests.sh
# Results: 16 passed, 0 failed, 1 skipped
sudo env PATH=$PATH:/usr/sbin bash tests/scripts/run_bwrap_inbound_deny_test.sh
# PASS: unsolicited inbound is dropped in the sandbox namespace
공식 스위트 (tests/scripts/run_bwrap_all_tests.sh, v1.0.0 태그) 결과
Basic, Environment(20개 단언), Filesystem, Read-Only Denial PASS
Version Gate(bwrap 없음·너무 오래된 bwrap 거부), Teardown(타임아웃 뒤 고아 bwrap·slirp4netns 없음) PASS
Provider Loss(실행 중 slirp4netns를 죽이면 실행 실패 처리) PASS
Object Validation, Most-Specific Path, Denied Masking(심볼릭 링크 우회 포함) PASS
Network Block, Network Firewall(CIDR 허용·거부 우선·변조 저항), allowLocalNetwork PASS
Network Proxy, Directional Network 1차: FAIL(테스트 도우미 unix-test-proxy 미빌드) → 도우미를 빌드한 2차: PASS
Linux Process Default(containment 생략·process 지정 시 bubblewrap으로 감) PASS
Inbound Deny (root 전용) 일반 스위트에선 SKIP → sudo로 단독 실행: PASS (MXC_INGRESS 체인 확인, 원치 않는 SYN 0→4개 DROP)
합계 16 passed / 0 failed / 1 skipped(1분 35초), root 테스트 포함 17/17 통과. PASS 단언 79줄

스위트의 테스트 이름이 곧 이 프로젝트가 무엇을 두려워하는지 보여 준다. 타임아웃 뒤 고아 프로세스가 남는지(Teardown), 실행 도중 네트워크 제공자가 죽으면 실패로 처리하는지(Provider Loss), 너무 오래된 bwrap을 거부하는지(Version Gate), 심볼릭 링크로 거부 경로를 우회할 수 있는지(Denied Masking). 일반 사용자 스위트는 root로 실행하면 일부러 시작을 거부한다. 권한을 버렸는지 검사하는 테스트가 root에서는 의미가 없기 때문이다.

돌리지 못한 것도 적어 둔다. Rust 단위·계약 테스트(cargo test -p mxc-sdk)는 박스의 rustc 1.85로는 의존성(icu_collections 2.2.0이 rustc 1.86 요구)에서 막혔다. 프로젝트 고정 버전인 1.93을 따로 설치하지 않았으므로 Rust 테스트 결과는 없다. Windows·macOS 백엔드, VM 계열 실험 백엔드, Node·.NET SDK API 호출도 실행하지 않았다.

위협 모델과 운영 주의점

MXC를 도입할 때 가장 먼저 정할 것은 ‘무엇으로부터 지키는가’다. 기본 Linux 경로는 프로세스 샌드박스다. 실수하는 에이전트, 엉뚱한 경로를 지우는 스크립트, 자격 증명을 실수로 업로드하는 코드에는 강력하다. 반면 커널 취약점을 노리는 공격 코드 앞에서는 VM이 아니라는 한계가 그대로 남는다. 생성된 bwrap 인자에 seccomp 필터는 보이지 않았다. 이 경우엔 실험적 VM 백엔드나 바깥 VM 계층이 맞는 답이다.

주의점 내용 대응
bwrap 경계의 한계 VM이 아니다. 호스트 커널을 그대로 공유하고, 생성된 argv에 seccomp 필터나 --new-session은 보이지 않았다 공격적인 코드라면 microvm·hyperlight 같은 VM 백엔드나 별도 VM 안에서 돌린다. 대화형 실행은 PTY API로 단말을 분리한다
호스트 도구를 그대로 공유 별도 rootfs가 없어 샌드박스 안 python3·curl은 호스트 버전. /etc도 읽기 전용으로 보인다 /etc에 비밀을 두지 않는다. 다른 배포판이 필요하면 LXC
HOME = 작업 디렉터리 process.cwd를 주면 그 폴더가 HOME이 돼 .gitconfig·.npmrc 같은 dotfile이 ‘사용자 설정’으로 읽힌다(문서 경고) 믿을 수 없는 작업 공간이면 HOME=…을 따로 지정
네트워크 모드 의존성 규칙·프록시 모드는 slirp4netns, iptables(nf_tables), nf_conntrack이 필요. iptables-legacy 호스트는 거부 배포 이미지에 의존성을 넣고 --available-backends로 사전 확인
IPv6 slirp4netns가 IPv6 없이 뜨므로 IPv6 허용 규칙은 사실상 아무것도 열지 않는다(차단은 유지) IPv4 목적지로 정책 작성
DROP으로 인한 지연 차단된 연결이 즉시 실패하지 않고 타임아웃까지 걸린다 작업 코드에 짧은 연결 타임아웃을 둔다
Node SDK 설치 Node 24+ 필수, node-pty 네이티브 빌드(make·컴파일러) 필요할 수 있음, 패키지 약 101MB CI 이미지에 빌드 도구 포함 또는 실행기 바이너리 직접 사용
텔레메트리 Microsoft 공식 빌드는 선택적 진단 데이터를 보낼 수 있다. 실행 단위 옵트인 + Windows 사용자 동의 + 관리자 정책 모두 필요, Windows 외에는 no-op 기업 환경은 관리자 정책으로 차단 가능(동의를 대신 줄 수는 없음)
Audit 모드 --audit는 샌드박스 보안을 끈다. 문서가 “믿을 수 없는 코드에 절대 쓰지 말라”고 경고 신뢰하는 도구의 정책을 만들 때만
라이선스 MIT(코드), 트레이드마크 정책 별도(TRADEMARKS.md) 사내 포크·재배포 자유. Microsoft 상표 사용만 주의

대안과 비교

선택지 격리 경계 MXC와 비교한 장점 MXC가 나은 점
Docker·Podman(rootless) 컨테이너(네임스페이스+cgroup), 별도 이미지 생태계·이미지·자원 제한이 성숙 데몬·이미지 없이 앱 안에서 바로. 호스트 도구를 그대로 쓰면서 경로 단위 정책
gVisor(runsc) 사용자 공간 커널로 시스템 호출 가로채기 커널 공격면을 크게 줄임 크로스 플랫폼(Windows·macOS). 설치·운영이 가볍다
Firecracker 계열(E2B 등 호스팅 샌드박스) 마이크로 VM 하드웨어 가상화 경계, 원격 실행 로컬 실행, 지연·비용 없음. MXC도 실험적 microvm·hyperlight 경로가 있다
nsjail·firejail·bwrap 직접 사용 같은 리눅스 프리미티브 단순·검증된 도구, seccomp 정책 세밀 OS별 백엔드를 하나의 계약으로 묶고, 집행 못 하는 정책은 거부하는 검증 계층
에이전트 내장 샌드박스(Codex CLI의 Seatbelt/Landlock, Anthropic sandbox-runtime 등) OS 샌드박스 + 프록시 해당 에이전트에 최적화 에이전트에 종속되지 않는 범용 SDK. Rust·.NET·Node 공통 API

정리하면 MXC의 차별점은 격리 기술 자체가 아니다. Bubblewrap, AppContainer, Seatbelt는 모두 원래 있던 OS 기능이다. 새로운 것은 그 위의 계약 계층이다. 하나의 정책 언어, 버전별로 정확히 맞춰야 하는 파서, 집행할 수 없는 정책을 실행 전에 거부하는 검증, 그리고 세 언어에서 같은 의미를 갖는 SDK. ‘샌드박스를 붙였다’와 ‘샌드박스 정책이 의도대로 집행된다는 걸 보장한다’ 사이의 간격을 메우려는 설계다.

누가 도입하면 좋은가, 어떻게 평가할까

잘 맞는 경우는 분명하다. 에이전트 기능을 데스크톱 앱이나 사내 도구에 넣으면서 Windows·macOS·Linux를 모두 지원해야 하는 팀, 사용자가 올린 플러그인이나 LLM이 생성한 코드를 로컬에서 실행해야 하는 제품, 그리고 Docker를 사용자 PC에 요구할 수 없는 환경이다. 반대로 서버에서 대량의 신뢰할 수 없는 작업을 돌리는 멀티테넌트 서비스라면, 지금은 마이크로 VM 기반 인프라가 더 맞는 출발점이다.

평가는 이렇게 권한다. 첫째, 실제 에이전트가 쓰는 작업 세 가지(테스트 실행, 패키지 설치, 데이터 분석 스크립트)를 골라 --dry-run으로 정책을 다듬고, 접근 거부가 나는 지점을 기록한다. Windows라면 문서의 Audit·learning 모드가 정책 작성을 돕지만 보안을 끄는 모드라 신뢰하는 도구에만 쓴다. 둘째, 일부러 나쁜 행동(홈 디렉터리 읽기, 거부 경로를 링크로 우회, 허용하지 않은 IP 접속, 타임아웃 초과)을 하는 테스트 작업을 만들어 CI에 넣는다. 위 표 7이 그 체크리스트의 초안이다. 셋째, 배포 이미지에서 --available-backends 결과를 고정 점검 항목으로 둔다. 네트워크 모드는 slirp4netns·iptables 유무에 따라 동작 여부가 갈리기 때문이다.

v1.0.0은 시작점이다. 안정 API 경계를 그었고, 실험 백엔드라는 다음 단계를 열어 뒀다. 에이전트 시대의 샌드박스 경쟁은 ‘얼마나 단단한 벽인가’만큼 ‘정책이 정확히 그 벽으로 번역되는가’에서 갈린다. MXC는 후자에 가장 많이 투자한 오픈소스 중 하나다.