안전한 AI 에이전트는 어떻게 만드는가 — NanoClaw 오픈소스 컨테이너 아키텍처
목차
AI 에이전트에게 Bash를 주면 강력해진다. 동시에 위험해진다. NanoClaw는 Docker 컨테이너 격리로 이 문제를 푸는 오픈소스 아키텍처다.
Telegram에서 메시지를 보내면, 격리된 Docker 컨테이너 안에서 Claude가 코드를 실행하고, 웹을 탐색하고, git push까지 한다. Host 시스템은 안전한 채로. 이 글은 그 “안전하게 Bash를 주는 구조”가 어떻게 설계되었는지, 그리고 왜 이런 결정을 했는지를 다룬다.
Claude Agent SDK를 컨테이너에 가두다
Claude Agent SDK(= Claude Code)는 원래 개발자의 로컬 터미널에서 동작하도록 만들어졌다. NanoClaw는 이것을 Docker 컨테이너 안에 가둬서, Telegram이라는 원격 인터페이스를 통해 안전하게 사용할 수 있게 만든 오픈소스 프로젝트다.
두 세계의 분리: Host와 Container
NanoClaw의 아키텍처를 한 문장으로 요약하면 이렇다:
Host는 배달부, Container는 두뇌.
flowchart LR
User["사용자<br>(Telegram)"]
Host["Host (Node.js)<br>grammy · 라우팅 · SQLite · 스케줄러<br>※ AI 처리를 하지 않는다"]
Container["Docker Container<br>Claude Agent SDK · Bash · 웹 브라우징 · MCP<br>※ 모든 AI 작업이 여기서 일어난다"]
User -- 메시지 --> Host
Host -- "spawn() + stdin JSON" --> Container
Container -- "stdout JSON 스트리밍" --> UserHost는 grammy(Telegram Bot 프레임워크)로 메시지를 받아서 컨테이너에 전달하고, 결과를 받아서 Telegram에 보낸다. 그게 전부다. AI가 뭘 하든 — 웹을 검색하든, 파일을 쓰든, apt install을 하든 — 컨테이너 안에서만 일어난다.
이 분리가 만드는 보장:
- 에이전트가 Bash를 마음껏 써도 Host 시스템은 무사하다
- 에이전트가 죽어도(OOM, 무한루프) Host는 정상 동작한다
- 그룹 A의 에이전트가 그룹 B의 데이터에 접근할 수 없다
- API 키와 OAuth 토큰이 컨테이너 파일시스템에 남지 않는다
메시지의 흐름
Telegram에서 “Vault에 Kubernetes 정리 노트 만들어줘”를 보내면 내부에서는 이런 일이 일어난다.
sequenceDiagram
participant U as 사용자 (Telegram)
participant H as Host (grammy)
participant C as Docker Container
U->>H: 메시지
H->>C: spawn() + stdin JSON
C->>C: Claude Agent SDK 작업 수행<br>(Bash, WebFetch, 파일 생성)
C->>H: stdout JSON 스트리밍
H->>U: Telegram 응답
Note over C: 컨테이너 종료 (일회성)메시지 수신 — 사용자가 Telegram에서 메시지를 보내면, grammy가 Long Polling으로 이를 감지하고 Host에 전달한다.
컨테이너 생성 — Host가 docker run -i로 새 컨테이너를 생성하고, stdin으로 프롬프트와 OAuth 토큰을 전달한다.
작업 수행 — 컨테이너 안에서 Claude Agent SDK가 Bash, WebFetch, 파일 생성 등 필요한 도구를 자율적으로 사용한다.
결과 반환 — 결과가 stdout JSON 스트리밍으로 Host에 반환되고, Host가 이를 Telegram 응답으로 사용자에게 전달한다.
컨테이너 종료 — 응답이 끝나면 컨테이너는 종료된다. 다음 메시지가 오면 새 컨테이너가 생성된다. 컨테이너가 상주하지 않으므로 대기 중 메모리를 차지하지 않고, 매번 깨끗한 상태에서 시작하므로 이전 세션의 부작용이 없다.
전체 과정이 수 초에서 수십 초. 사용자 입장에서는 Telegram에서 대화하는 것처럼 느껴진다.
왜 HTTP가 아니라 stdin/stdout인가
Host와 Container가 통신하는 방식이 독특하다. REST API 서버를 띄우는 게 아니라, 프로세스의 stdin/stdout 파이프를 사용한다.
flowchart TB
Q{"Host ↔ Container<br>통신 방식은?"}
Q -->|"HTTP"| A["포트 오픈<br>서버 구동<br>연결 풀링"]
Q -->|"stdin/stdout (채택)"| B["포트 없음<br>인프라 없음<br>시크릿 안전"]처음에는 이상하게 느껴질 수 있다. HTTP가 훨씬 익숙하고 범용적이니까. 하지만 이 맥락에서는 파이프가 정답이다:
- 포트를 열지 않는다: 네트워크 리스닝이 없으므로 외부에서 접근할 수 없다
- 인프라가 없다: 서버 구동, 포트 관리, 연결 풀링이 필요 없다
- 시크릿이 안전하다: OAuth 토큰을 stdin으로 보내고 끝. 환경변수나 파일에 남지 않는다
Host의 Node.js 프로세스가 child_process.spawn()으로 Docker를 실행하고, 커널의 fork() + exec()가 파이프를 만든다. 운영체제가 제공하는 가장 기본적인 IPC 메커니즘이다.
NanoClaw의 철학은 개인 노트북에서 돌아간다는 것이다. 집 WiFi, 카페, 테더링 — 어디서든 인터넷만 있으면 동작해야 한다. Telegram Long Polling 방식으로 동작하기 때문에 공인 IP나 도메인이 필요 없다. NAT 뒤에 있어도, IP가 바뀌어도, outbound 연결만 되면 즉시 동작한다.
보안: 컨테이너 격리가 만드는 보장
에이전트에게 Bash를 주면서 안전하려면, 한 겹이 아니라 여러 겹의 방어가 필요하다.
파일시스템 격리 — 그룹별로 Docker 볼륨 마운트가 다르다. A 그룹의 컨테이너에서는 B 그룹의 폴더가 아예 보이지 않는다. 마운트 자체가 안 되니까 우회할 방법이 없다.
시크릿은 stdin으로만 — OAuth 토큰은 프로세스의 표준 입력으로 전달되고 끝이다. 환경변수, 파일시스템, 프로세스 목록 어디에도 남지 않는다. docker inspect로 봐도 안 나온다.
마운트 허용 목록 — mount-allowlist.json에 등록된 경로만 마운트할 수 있다. 설정을 바꿔서 /etc/passwd를 마운트하려 해도 거부된다.
이 외에도 프로젝트 루트 read-only 마운트, 그룹별 IPC 디렉토리 분리, 세션 데이터 격리 등 총 6개 레이어로 구성되어 있다.
스킬: 같은 시스템, 두 가지 맥락
NanoClaw의 코어는 의도적으로 작게 유지된다. 하나의 프로세스, 몇 개의 소스 파일, 마이크로서비스 없음. 기능을 코드베이스에 직접 추가하는 대신, Claude Code 스킬이 사용자의 fork를 변형하는 방식을 택했다. Features over code가 아니라 Skills over features다. 그 결과, 코어는 이해할 수 있을 만큼 작은 상태를 유지하면서도, 각 사용자의 NanoClaw는 자신에게 필요한 기능만 갖춘 맞춤형이 된다.
그리고 같은 스킬 메커니즘이 전혀 다른 두 맥락에서 동작한다.
개발자가 쓰는 스킬 (Host) — NanoClaw 자체를 구성하고 확장하는 메타 스킬이다. 처음 설치할 때 setup을 한 번 거치면, 이후에는 코딩 없이 채널 추가와 기능 확장이 가능하고, upstream 업데이트 동기화까지 스킬로 처리한다.
/setup— 최초 설치, 인증, 서비스 설정을 처리한다/add-telegram— Telegram 채널 코드가 프로젝트에 자동 추가된다/add-gmail— Gmail 연동 코드와 OAuth 설정이 생성된다/update-nanoclaw— upstream 변경사항을 미리보고 선택적으로 반영한다
$ claude
> /setup
Setting up NanoClaw...
Dependencies installed
Telegram authenticated
Service registered and started
> /add-telegram
Adding Telegram channel...
src/channels/telegram.ts created
Bot token configured
Polling mode enabled
> /update-nanoclaw
Fetching upstream changes...
3 new commits found
Applied: fix CJK font rendering
Skipped: webhook support (fork custom)에이전트가 쓰는 스킬 (Container) — AI가 사용자 요청에 응답하면서 사용하는 도구다. 코드가 아니라 마크다운으로 작성된 자연어 명세가 AI의 행동 지침이 된다. container/skills/에 마크다운 파일 하나를 추가하면 에이전트의 능력이 확장된다.
agent-browser— 웹 페이지를 열고, 클릭하고, 스크린샷을 찍는다add-image-vision— 이미지를 인식하고 분석한다add-pdf-reader— PDF 파일을 읽고 텍스트를 추출한다
사용자: 이 PDF 요약해줘 [파일 첨부]
에이전트: (add-pdf-reader로 텍스트 추출)
(Claude가 내용 분석)
"이 문서는 Kubernetes 1.35의 주요 변경사항을
다루고 있습니다. 핵심은 In-Place Pod Resize가
GA로 승격된 것과..."하나는 NanoClaw를 만드는 데, 하나는 NanoClaw가 일하는 데 쓰인다. Claude Code의 스킬 아키텍처가 이런 이중 활용에 자연스럽게 맞아떨어졌다.
왜 이 구조를 선택했는가
AI 에이전트 아키텍처를 설계할 때 세 가지 선택지가 있었다:
프로세스 내 직접 실행 (OpenClaw 방식) — 가장 간단하다. Host의 Node.js 프로세스에서 Claude API를 직접 호출하면 된다. 하지만 에이전트가 Bash를 실행하면 Host 시스템에서 그대로 실행된다. 격리가 없다.
VM 기반 격리 — 가장 강력하다. 커널 수준의 완전한 격리를 제공한다. 하지만 VM은 무겁다. 메시지 하나마다 VM을 띄우면 시작 시간이 수십 초다. 개인 노트북에서 돌리기엔 리소스도 부담이다.
컨테이너 기반 격리 — NanoClaw가 선택한 방식이다. Docker 컨테이너는 수 초 만에 시작되고, 볼륨 마운트로 유연하게 파일시스템을 제어할 수 있고, 리소스 오버헤드가 적다. 엔터프라이즈급 보안은 아니지만, “실수로 Host를 망가뜨리는” 것은 확실히 막는다.
그리고 Claude Agent SDK 위에 만들어졌다는 점이 결정적이었다. 스킬 시스템, MCP 도구, 에이전트 기능을 처음부터 만들 필요가 없었다. Claude Code가 터미널에서 할 수 있는 모든 것을, Docker 컨테이너 안에서 그대로 할 수 있게 감싸는 것이 NanoClaw의 접근이다.
정리
NanoClaw의 아키텍처는 하나의 질문에서 시작되었다: AI 에이전트에게 Bash를 줘도 안전한 구조가 가능한가?
Host-Container 분리로 AI 작업을 격리하고, stdin/stdout 파이프로 포트 없이 통신하고, 볼륨 마운트 허용 목록으로 파일시스템을 보호한다. 코어는 500줄 남짓으로 유지하면서, 나머지는 스킬이 각 사용자의 fork를 맞춤 변형한다. Claude Agent SDK 위에서 바퀴를 다시 발명하지 않고, 안전하게 감싸는 것이 NanoClaw의 본질이다.
그리고 스킬 시스템은 개발 방식 자체를 바꿔놓았다. 기존에는 코드를 직접 구현하고, 동작을 점검하고, 테스트를 작성해서 마무리했다. 스킬은 다르다. 마크다운에 구현 방향을 명시하고 방향을 제시하면, 실제 코드 생성은 Claude Code에 위임된다. 코드를 짜는 게 아니라 코드가 만들어질 명세를 작성하는 셈이다.
에피소드: 500줄 코드의 함정
처음 NanoClaw를 fork했을 때, 코어 코드가 500줄 남짓인 게 불안했다. 기능이 너무 부족해 보였다. 그래서 코어에 직접 기능을 추가하는 PR을 upstream에 올렸다. 돌아온 답은 정중했지만 명확했다 — NanoClaw는 코어를 작게 유지하는 게 의도된 설계라는 것.
그제야 Skills over features 철학을 이해했다. 코어에 기능을 넣으면 모든 사용자가 그 코드를 떠안는다. 스킬로 만들면 필요한 사용자만 자기 fork에 적용한다. PR을 자진 close하고, 같은 기능을 스킬로 다시 만들었다. 지금은 그 방식이 훨씬 자연스럽다.
에피소드: AI의 기억을 디버깅하다
새로운 종류의 디버깅도 생겼다. Claude Code의 에이전트는 대화를 통해 학습한 패턴과 규칙을 자체 메모리에 누적한다. 한번 잘못된 패턴이 기록되면, 스킬 규칙을 아무리 고쳐도 같은 실수를 반복한다. 스킬이 아니라 에이전트의 메모리를 찾아서 정리해야 비로소 해결된다. 코드가 아니라 AI의 기억을 디버깅하는 경험은 꽤 새로웠다.