프로그램은 케이지가 잠겨 있다고 했지만, 나는 커널에게 물었다
요약
NVIDIA의 NOOA 프레임워크를 사용하여 AI 에이전트의 코드 실행 격리 환경을 검증한 사례를 다룹니다. 프레임워크가 제공하는 샌드박스 설정이 실제 OS 레벨(seccomp 등)에서 의도대로 적용되었는지 커널을 통해 직접 확인하는 과정을 설명합니다.
핵심 포인트
- AI 에이전트 프레임워크의 격리 경계는 OS 레벨의 격리가 핵심임
- 프레임워크의 자체 보고와 실제 커널 상태(seccomp 등)는 다를 수 있음
- 샌드박스 설정이 opt-in 방식이므로 실제 활성화 여부 확인이 필수적임
- 커널을 통한 프로세스 상태 확인이 가장 강력한 검증 수단임
프레임워크는 샌드박스 (sandbox)가 적용되었다고 말했습니다. 나는 다른 의견을 듣고 싶어서 커널 (kernel)에게 물었습니다.
$ grep Seccomp /proc/10920/status /proc/10922/status
/proc/10920/status:Seccomp: 0
/proc/10920/status:Seccomp_filters: 0
...
10920은 에이전트 (agent)입니다. 10922는 모델이 작성한 코드를 실행하기 위해 포크 (fork)한 워커 (worker)입니다. 워커에는 seccomp 필터가 로드되어 있지만 부모 프로세스에는 로드되어 있지 않습니다. 이것이 바로 네트워크 차단이며, 정확히 그것을 가져야 하는 프로세스에만 적용되고 다른 곳에는 적용되지 않았음을 의미합니다.
이 확인에는 10초가 걸렸으며, 이는 이 프로젝트 전체에서 프로그램 자체의 보고 외에 다른 것을 통해 내가 검증한 첫 번째 사항입니다.
내가 하고 있었던 일
나는 언어 모델 (language model)이 작성한 코드를 실행하는 AI 에이전트 프레임워크를 실행합니다. 내가 연구해 온 것은 NVIDIA의 NOOA입니다. 이 프레임워크의 자체 문서는 이례적으로 직설적입니다. 정적 검사 (static checks)와 거부 목록 (deny-lists)은 가드레일 (guardrails)이지 격리 경계 (containment boundary)가 아니며, 실제 경계는 OS 레벨의 격리 (OS-level isolation)라는 점입니다.
이 프레임워크는 격리 환경을 제공합니다. 생성된 코드의 각 블록은 Landlock이 파일 시스템을 제한하고, seccomp가 네트워크 소켓을 차단하며, 리소스 제한 (resource caps) 및 엄격한 타임아웃 (hard timeout)이 적용된 포크된 워커에서 실행됩니다. 그들의 논문 부록 D.2에서는 이를 배포하는 방법을 설명하며, 이를 보완하는 백스톱 (backstop)과 함께 공개된 자체 프로세스 내 가드 (in-process guard)의 알려진 격차에 대해서도 기술하고 있습니다.
따라서 질문은 설계가 견고한지 여부가 아니었습니다. 설계는 견고합니다. 질문은 논문에 기술된 내용이 실제로 내 머신에서 실행되고 있는지였습니다.
첫 번째 놀라움
실행되고 있지 않았습니다.
execution_backend: Literal["inprocess", "sandbox"] = "inprocess"
OS 샌드박스는 선택 사항 (opt-in)입니다. 내가 수행한 모든 에이전트 실행은 에이전트 자신의 프로세스 내에서 모델이 작성한 Python을 실행했으며, 이는 문서에서 명시적으로 격리 경계가 아니라고 말하는 AST 검증기 (AST validator)와 거부 목록 (deny-lists)에 의해 보호되고 있었습니다.
아무런 문제도 없었습니다. 제가 구축한 VM (Virtual Machine)은 README에 명시된 대로 정확히 작동하고 있었습니다. 하지만 저는 소스 코드를 읽었기 때문에 특정 계층이 존재할 것이라고 가정하고 있었습니다. 소스 코드를 읽는 것은 실제로 실행된 것을 확인하는 것과는 다릅니다.
커널이 알려주는 것과 알려주지 않는 것
보호 장치들을 활성화하자, 검증 가능한 상태가 되었습니다. 모든 장치가 동일한 방식으로 확인되는 것은 아니었습니다.
Seccomp는 프로세스별로 읽을 수 있습니다. 이것이 위에서 언급한 차이점이며, 사용 가능한 가장 강력한 종류의 증거입니다. 즉, 프로세스가 스스로를 보고하는 것이 아니라 커널이 프로세스에 대해 보고하는 것입니다.
**Resource caps (리소스 제한)**는 두 프로세스 모두에서 '무제한'으로 읽혔습니다. 설정을 읽기 전까지는 이것이 취약점으로 보였습니다. 하지만 max_memory_mb와 max_cpu_seconds 설정이 모두 기본값으로 0이었는데, 이는 비활성화를 의미합니다. 아무것도 요청되지 않았기에 아무것도 적용되지 않았습니다. 설정과 커널의 상태가 일치했습니다. 제가 단지 설정을 읽지 않았을 뿐이었습니다.
Landlock은 다시 읽어올 수(read back) 없습니다. 프로세스가 규칙 세트(ruleset)를 적용하고 나면 그 제한은 실제적이며 돌이킬 수 없지만, 이를 위한 /proc 필드는 존재하지 않습니다. 차이점을 이용한 트릭이 통하지 않습니다. 이를 확인하는 유일한 방법은 행동 기반(behavioural) 방식뿐입니다. 격리된 프로세스가 허용된 경로 외부의 무언가를 읽으려고 시도하게 하고, 그것이 실패하는 것을 지켜보는 것입니다.
이 점은 깊이 생각해 볼 가치가 있습니다. 세 가지 보호 장치 중 하나는 직접 관찰 가능하고, 하나는 설계상 확인이 불가능하며, 하나는 오직 증명될 수만 있습니다. 여러분의 샌드박스(sandbox)가 유지되고 있는지 알고 싶다면, 이 중 그 어떤 것에 대해서도 "설정했습니다"라는 말은 답이 될 수 없습니다.
그들의 테스트는 이미 이를 수행하고 있습니다
Landlock 프로브(probe)를 작성하려던 참에, NVIDIA가 이미 작성해 둔 것을 발견했습니다. 무려 46개나 말이죠.
$ uv run pytest tests/runtime/sandbox/ -m integration -q
46 passed, 23 deselected in 22.48s
22초가 걸린 이유는 그들이 가짜 LLM 클라이언트(fake LLM client)를 사용하기 때문입니다. 모델도, 추론(inference)도, 자격 증명(credentials)도 필요 없습니다. 격리(containment) 여부를 출력 결과를 읽는 데 걸리는 시간만큼의 짧은 시간 안에 테스트할 수 있게 됩니다.
그리고 그것들은 당신이 원하는 방식대로 구축되어 있습니다. test_guards.py에는 test_file_read_leak_without_sandbox와 test_file_read_closed_with_sandbox가 있습니다. 먼저 유출(leak)을 확인한 다음, 닫힌(closed) 상태를 확인합니다. 메모리나 네트워크도 마찬가지입니다. 이 테스트들은 가드(guard)가 꺼져 있을 때 동일한 현상이 실패하는 것을 먼저 보여주지 않으면, 통과(passing) 판정을 내리지 않습니다.
이것은 제가 연구 중이던 프로젝트의 스위트(suite)에 앉아 모든 가드레일(guardrail)에 적용하며, 전체 포스트를 통해 작성했던 바로 그 규율(discipline)입니다.
그런 다음 나는 그것들이 실행되는지 확인했다
run: uv run pytest -q -m "not integration and not stress"
이것은 ci.yml의 38행이며, 전체 워크플로(workflow) 디렉토리에서 유일한 pytest 호출입니다. 46개의 모든 격리(containment) 테스트는 integration 마커(marker)를 가지고 있습니다. 그중 어느 것도 CI에서 실행되지 않습니다.
이 제외(exclusion)는 부주의한 것이 아닙니다. 12개의 테스트 파일이 해당 마커를 가지고 있으며, 그중 6개는 실제로 API 자격 증명(credentials)이 필요한 라이브 프로바이더(live-provider) 테스트로, CI에서는 전혀 실행될 수 없습니다. 해당 그룹에서 이 마커는 "자격 증명 필요"를 의미하고, 샌드박스(sandbox) 그룹에서는 "실제 워커(worker)를 포크(fork)함"을 의미하며, 하나의 필터가 이 둘을 모두 잡아냅니다.
나는 명백한 방어 기제를 확인했습니다. 아마도 샌드박스 테스트가 Landlock이나 seccomp가 없는 러너(runner)에서 실패할 수도 있다고 생각했습니다. 하지만 그렇지 않았습니다. 파일 내의 모든 SandboxConfig는 require=False를 통과하며, 특정 메커니즘이 필요한 4개의 테스트는 스킵(skip) 조건을 가지고 있습니다. 해당 기능이 없는 커널에서는 실패하는 대신 스킵됩니다.
결론은 이렇습니다. 올바르게 작성되었고, 쌍을 이룬 부정 대조군(negative controls)을 갖춘 작동 가능한 격리 스위트가, 단 한 번도 자동으로 실행된 적이 없다는 것입니다. 고장 난 가드가 아닙니다. 아무도 지켜보지 않는 가드입니다.
나는 이를 issue #78로 등록했습니다.
내가 똑똑해 보이는 것을 멈추는 부분
이 모든 일이 진행되는 동안, 내가 직접 만든 검증 스크립트가 두 번이나 깨졌습니다.
6일 전 나는 첫 번째 버전에 대해 썼습니다. 그 버전은 구조적으로 초록색 OK를 출력하지 않을 수 없게 설계되어 있었습니다. 나는 그것을 수정하고, 플랫폼이 보고하는 전체 구성 키 (configuration keys) 세트를 이미 알고 있는 정상 기준선 (baseline)과 비교하는 체크 기능을 추가했습니다. 이렇게 하면 키 이름이 변경되었을 때, 내가 출력이 짧아진 것을 직접 알아차릴 필요 없이 기계적으로 실패 처리가 됩니다.
그 후, 드리프트 (drift)와는 전혀 상관없는 이유로 실패가 발생했습니다.
나는 VM (가상 머신)이 실행 중일 때 기준선을 캡처했습니다. 실행 중인 VM은 전원이 꺼진 VM이 보고하지 않는 키들을 보고하므로, 상태 간의 비교 과정에서 10개의 키가 이름 변경(rename)으로 분류되었습니다. 10번의 실패가 발생했지만, 실제로는 아무런 문제가 없었습니다.
나는 기준선에 상태를 기록하고 다시 캡처하여 이를 수정했지만, 또다시 실패했습니다. 게스트 (guest)가 보고한 4개의 키가 추가로 발견되었는데, 이 키들은 부팅 후 약 1분 뒤에 게스트가 자신의 기능 (facilities)을 등록하면 나타나는 것들이었습니다. 나는 부팅 30초 시점에 캡처를 했던 것입니다.
세 번의 버전, 세 번의 실패, 모두 같은 부류였습니다. 즉, 체크 기능이 실제로 실행되는 조건들 사이에서 현실과의 관계가 검증되지 않았던 것입니다. 실패할 수 없었던 기능이, 상태 간의 비교에서는 허위로 작동했고, 타이밍에 의존하는 단일 상태 내에서도 허위로 작동했습니다.
두 번째 실패를 잡아낸 것은 내가 예측하지 못했던 부분이었습니다. 한 시간 전, 나는 실패를 묵인하는 비용을 높게 책정하도록 설계된 재생성 (regeneration) 스크립트를 작성했습니다. 강제 플래그 (force flag)도 없고, 비대화형 모드 (non-interactive mode)도 없으며, 삭제한 키는 반드시 수동으로 다시 입력해야만 합니다. 실제 실패를 조용히 삭제할 수 없도록 만든 것입니다. 그런데 이 스크립트의 첫 번째 행동은, 가짜 실패를 조용히 삭제하려는 나를 막아서는 것이었습니다.
다른 방향에서 온 또 하나의 문제
같은 주에, 누락된 API 키 때문에 오후 시간을 통째로 날렸습니다.
에러 메시지에는 InternalServerError라고 적혀 있었습니다. 500 에러였습니다. 그래서 세 번의 재시도 (retry)가 이루어졌고, 유용한 문장은 200줄에 달하는 트레이스백 (traceback) 맨 아랫부분에 나타났습니다.
연쇄 과정은 다음과 같습니다: OpenAI SDK는 클라이언트 생성 시점에, 즉 어떠한 HTTP 요청이 발생하기도 전에 예외를 발생시키므로, 해당 예외에는 상태 코드 (status code)가 포함되어 있지 않습니다. litellm의 핸들러 (handler)는 누락된 상태 코드를 기본값인 500으로 설정합니다. 매퍼 (mapper)는 500을 확인하고 이를 서버 에러 (server error)라고 부릅니다.
하지만 상태 코드가 누락되었다는 것은 어떠한 HTTP 교환도 일어나지 않았음을 의미합니다. 이를 500으로 기본 설정하는 것은 서버가 서버 에러와 함께 응답했다고 단정 짓는 것입니다. 아무것도 응답하지 않았습니다. 아무것도 요청되지 않았습니다.
여기 있는 다른 모든 사례와 같은 형태입니다: 자신이 알 수 있는 위치에 있지 않았던 무언가에 대해 한 계층 (layer)이 자신 있게 보고하고 있는 것입니다. litellm #35860으로 접수되었습니다.
내가 실제로 얻은 교훈
이 스택 (stack)의 모든 계층은 자기 자신에 대해 보고하며, 그 보고들의 가치는 해당 계층이 그것에 대해 틀릴 수 있는 능력만큼만 가치를 가집니다.
프레임워크는 샌드박스 (sandbox)가 적용되었다고 말합니다. 그것은 자신이 요청했다고 보고하는 것입니다. 테스트 스위트 (test suite)는 통과 (green)라고 말합니다. 그것은 실행된 테스트에 대해 보고하는 것이지, 필터링되어 제외된 테스트에 대해 보고하는 것이 아닙니다. 내 스크립트는 설정이 일치한다고 말합니다. 그것은 자신이 찾아보려고 생각했던 키 (keys)들에 대해, 전달받은 상태가 어떠했든 간에 보고하는 것입니다.
이 중 어느 것도 거짓말은 아닙니다. 그것들은 모두 들리는 것보다 훨씬 더 좁은 범위의 주장입니다.
유용한 질문은 "문제가 없다고 말하는가"가 아닙니다. "그렇게 말하기 위해서 무엇이 참이어야 하는가, 그리고 그중 어떤 것이 자기 자신이 아닌 다른 것에 의해 확인되는가"입니다.
때로는 바로 그 자리에 답이 놓여 있습니다. 커널 (kernel)은 어떤 프로세스가 seccomp 필터를 가지고 있는지 알고 있습니다. 콘텐츠 주소 지정 저장소 (content-addressed store)의 파일 이름은 체크섬 (checksums)입니다. 테스트 스위트는 어떤 테스트를 건너뛰었는지 알고 있습니다. 이 중 그 어떤 것도 당신이 확인하려는 대상을 신뢰할 필요를 요구하지 않습니다.
그리고 Landlock처럼 답이 없는 경우도 있는데, 그럴 때 유일하게 정직한 방법은 의도적으로 무언가를 망가뜨려 보고 어떤 일이 일어나는지 지켜보는 것입니다.
호기심이 생기는 분들을 위해
두 가지 명령어. 만약 에이전트 (agents)를 샌드박스에서 실행한다면, 차이점은 다음과 같습니다:
ps -eo pid,ppid,comm | grep python # 부모 프로세스와 포크된 워커(worker)를 찾습니다
grep Seccomp /proc/<parent>/status /proc/<worker>/status
grep -E "Max address space|Max cpu time" /proc/<worker>/limits
워커(worker)에는 필터가 적용되어 있고 부모(parent)에는 적용되어 있지 않다면, 가드(guard)가 제 역할을 수행하고 있는 것입니다. 양쪽 모두 값이 동일하다면 가드가 당신이 생각하는 위치에 있지 않다는 것을 의미합니다.
리소스 제한(resource caps)이 무제한으로 읽히는 이유. 이 값들은 기본적으로 비활성화(disabled)되어 있으며, 이는 사용자의 워크로드(workload)를 알 수 없는 프레임워크 입장에서는 방어 가능한 선택입니다. 이는 새로운 샌드박스(sandbox)가 네트워크를 차단하고 파일 시스템(filesystem)을 가두지만, 사용자가 요청하기 전까지는 메모리나 CPU를 제한하지 않는다는 것을 의미합니다.
스크립트(Scripts). VM 설정, 검증기(verifier), 그리고 재생성 도구(regeneration tool)는 버그를 포함한 상태 그대로 ai-security-lab에 있습니다. 커밋 히스토리(commit history)에 세 가지 실패 사례가 포함되어 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기