안전한 코드 실행

안전한 코드 실행

출처: Secure code execution - Hugging Face smolagents 공식 문서

에이전트를 처음 만들어 보려는 분이라면 먼저 에이전트 소개smolagents 둘러보기를 읽어 두면 훨씬 수월해요. 이 글은 코드 에이전트를 안전하게 굴리기 위해 smolagents가 무엇을 준비해 두고 있는지, 그리고 여러분이 어떤 선택지를 골라야 하는지를 설명합니다.

코드 에이전트가 왜 더 나은가요

여러 논문(2411.01747, 2401.00812)에서 공통적으로 밝힌 사실이 있어요. LLM이 자기 행동(즉 툴 호출)을 코드로 작성하게 하는 것이, 업계에서 널리 쓰는 "행동을 툴 이름과 인자의 JSON으로 쓰기" 형식보다 훨씬 낫다는 거죠.

왜 코드가 더 나을까요? 우리가 코드 언어를 만들 때 컴퓨터가 수행할 행동을 표현하는 데 아주 능숙하도록 다듬었기 때문입니다. 만약 JSON 조각이 더 좋은 방법이었다면, 이 패키지 자체가 JSON 조각으로 쓰여 있었을 거예요.

코드는 컴퓨터에서의 행동을 표현하는 더 좋은 방법입니다. 이런 점에서 더 낫습니다.

  • 조합성(Composability): JSON 행동을 서로 중첩하거나, 나중에 재사용할 행동 묶음을 정의하는 건 어떨까요? 파이썬 함수 하나를 정의하는 것과 같죠.
  • 객체 관리(Object management): generate_image 같은 행동의 출력을 JSON에서는 어떻게 저장할 수 있나요?
  • 일반성(Generality): 코드는 컴퓨터가 할 수 있는 일이라면 무엇이든 표현하도록 만들어져 있어요.
  • LLM 학습 데이터에서의 표현: 이미 좋은 행동이 LLM 학습 데이터에 많이 포함되어 있다는 이 축복을 왜 활용하지 않겠어요.

아래 그림은 Executable Code Actions Elicit Better LLM Agents에서 가져온 것으로, 이 차이를 잘 보여줍니다.

그래서 smolagents는 코드 에이전트, 그중에서도 파이썬 에이전트를 제안하는 데 힘을 쏟았고, 이는 곧 안전한 파이썬 인터프리터를 만드는 데 더 많은 노력을 들였다는 뜻이 됩니다.

로컬 코드 실행은 위험해요

기본적으로 CodeAgent는 LLM이 생성한 코드를 여러분의 환경에서 실행합니다.

이것은 본질적으로 위험합니다. LLM이 만든 코드가 내 환경을 해칠 수 있기 때문이죠.

악성 코드 실행은 여러 경로로 일어날 수 있어요.

  • 순수한 LLM 실수: LLM은 아직 완벽하지 않아서, 도움이 되려다가 실수로 위험한 명령을 만들 수도 있어요. 위험은 낮지만, LLM이 위험한 코드를 실행하려 한 사례가 관찰된 적은 있습니다.
  • 공급망 공격(Supply chain attack): 신뢰할 수 없거나 손상된 LLM을 실행하면 시스템이 유해한 코드 생성에 노출될 수 있어요. 잘 알려진 모델을 안전한 추론 인프라에서 쓸 때는 이 위험이 극히 낮지만, 이론상 가능성은 남아 있습니다.
  • 프롬프트 인젝션(Prompt injection): 웹을 탐색하던 에이전트가 악성 웹사이트에 도달하면, 그 사이트의 유해한 지시가 에이전트의 메모리로 주입되는 공격을 받을 수 있어요.
  • 공개된 에이전트의 악용(Exploitation): 공개된 에이전트는 악의적인 행위자가 유해 코드를 실행하도록 악용할 수 있어요. 공격자는 적대적 입력을 정교하게 만들어 에이전트의 실행 능력을 꺾고 의도하지 않은 결과를 만들 수 있습니다.

일단 악성 코드가 실행되면, 우연이든 의도든, 파일 시스템을 망가뜨리고 로컬·클라우드 리소스를 악용하며 API 서비스를 남용하고 네트워크 보안까지 위협할 수 있어요.

에이전트성의 스펙트럼 관점에서 보면, 코드 에이전트는 다른 덜 에이전트적인 구성보다 LLM에 훨씬 큰 자율성을 줍니다. 그만큼 위험도 커지는 거예요.

그래서 보안을 매우 신중하게 생각해야 합니다.

안전을 높이기 위해 저희는, 설정 비용은 더 들지만 더 높은 수준의 보안을 제공하는 여러 조치를 제안합니다.

한 가지 명심하세요. 100% 안전한 해법은 없습니다.

로컬 파이썬 실행기(Local Python Executor)

첫 번째 보안 계층을 더하기 위해, smolagents의 코드 실행은 순수 파이썬 인터프리터로 수행되지 않습니다. 더 안전한 LocalPythonExecutor를 처음부터 다시 만들었어요.

정확히 말하면, 이 인터프리터는 코드에서 추상 구문 트리(AST, Abstract Syntax Tree)를 읽어 연산 하나하나를 실행하면서 다음 규칙을 항상 지킵니다.

  • 기본적으로 import는, 사용자가 승인 목록에 명시적으로 추가하지 않으면 금지됩니다.
  • 게다가 하위 모듈 접근도 기본적으로 차단되며, 각각을 import 목록에 명시적으로 허가해야 해요. 예를 들어 numpy.*를 주면 numpynumpy.random이나 numpy.a.b 같은 모든 하위 패키지를 함께 허용합니다.
    • 참고로, random처럼 무해해 보이는 일부 패키지도 random._os처럼 유해할 수 있는 하위 모듈에 접근을 허용할 수 있어요.
  • 처리되는 기본 연산의 총 개수에 상한을 두어 무한 루프와 리소스 폭증을 막습니다.
  • 우리가 만든 인터프리터에 명시적으로 정의되지 않은 연산은 오류를 냅니다.

이런 안전 장치를 직접 확인해 볼까요.

from smolagents.local_python_executor import LocalPythonExecutor

# 커스텀 실행기를 만들고 "numpy" 패키지를 승인
custom_executor = LocalPythonExecutor(["numpy"])

# 오류를 보기 좋게 출력하는 유틸리티
def run_capture_exception(command: str):
    try:
        custom_executor(harmful_command)
    except Exception as e:
        print("ERROR:\n", e)

# 정의되지 않은 명령은 그냥 동작하지 않음
harmful_command="!echo Bad command"
run_capture_exception(harmful_command)
# >>> ERROR: invalid syntax (<unknown>, line 1)


# os 같은 import는 `additional_authorized_imports`에 명시적으로 추가하지 않으면 실행되지 않음
harmful_command="import os; exit_code = os.system('echo Bad command')"
run_capture_exception(harmful_command)
# >>> ERROR: Code execution failed at line 'import os' due to: InterpreterError: Import of os is not allowed. Authorized imports are: ['statistics', 'numpy', 'itertools', 'time', 'queue', 'collections', 'math', 'random', 're', 'datetime', 'stat', 'unicodedata']

# 승인된 import 안에서도, 유해할 수 있는 패키지는 import되지 않음
harmful_command="import random; random._os.system('echo Bad command')"
run_capture_exception(harmful_command)
# >>> ERROR: Code execution failed at line 'random._os.system('echo Bad command')' due to: InterpreterError: Forbidden access to module: os

# 무한 루프는 N회 연산 후 중단됨
harmful_command="""
while True:
    pass
"""
run_capture_exception(harmful_command)
# >>> ERROR: Code execution failed at line 'while True: pass' due to: InterpreterError: Maximum number of 1000000 iterations in While loop exceeded

이런 안전 장치 덕분에 인터프리터가 더 안전해졌어요. 여러 다양한 사용 사례에서 환경에 아무런 손상 없이 사용해 왔습니다.

[!WARNING] 어떤 로컬 파이썬 샌드박스도 완전히 안전할 수는 없다는 걸 꼭 이해하세요. 우리 인터프리터가 표준 파이썬 인터프리터보다 훨씬 안전하긴 하지만, 결심한 공격자나 정교하게 튜닝된 악성 LLM이 취약점을 찾아 여러분의 환경을 해칠 가능성은 여전히 있습니다.

예를 들어, Pillow 같은 패키지로 이미지를 처리하도록 허용했다면, LLM이 수천 개의 큰 이미지 파일을 만들어 하드디스크를 가득 채우는 코드를 생성할 수도 있어요. 다른 고급 탈출 기법은 승인된 패키지의 더 깊은 취약점을 악용할 수도 있습니다.

로컬 환경에서 LLM 생성 코드를 실행하는 것은 항상 어느 정도의 고유한 위험을 수반합니다. 진짜 견고한 보안 격리를 원한다면 아래에서 자세히 설명하는 E2B나 Docker 같은 원격 실행 옵션을 쓰는 게 유일한 방법입니다.

신뢰할 수 있는 추론 제공자에서 잘 알려진 LLM을 쓸 때 악성 공격의 위험은 낮지만, 0은 아닙니다. 보안이 중요한 애플리케이션이거나 덜 신뢰할 수 있는 모델을 쓴다면 원격 실행 샌드박스를 고려해 보세요.

안전한 코드 실행을 위한 샌드박스 접근법

코드를 실행하는 AI 에이전트를 다룰 때 보안은 최우선입니다. smolagents에서 코드 실행을 샌드박싱하는 방법은 크게 두 가지이며, 각각 보안 특성과 능력이 다릅니다.

샌드박스 접근법 비교

  1. 코드 조각만 샌드박스에서 실행하기: 이 접근법(그림 왼쪽)은 에이전트가 생성한 파이썬 코드 조각만 샌드박스에서 실행하고, 에이전트 시스템의 나머지는 로컬 환경에 둡니다. executor_type="blaxel", executor_type="e2b", executor_type="modal", executor_type="docker" 로 간단히 설정할 수 있지만, 멀티 에이전트를 지원하지 않고 환경과 샌드박스 사이에 상태 데이터를 전달해야 한다는 단점이 있어요.

  2. 에이전트 시스템 전체를 샌드박스에서 실행하기: 이 접근법(그림 오른쪽)은 에이전트, 모델, 툴을 포함한 에이전트 시스템 전체를 샌드박스 환경에서 실행합니다. 격리가 더 좋지만 수동 설정이 더 필요하고, API 키 같은 민감한 자격 증명을 샌드박스로 전달해야 할 수도 있어요.

이 가이드에서는 두 가지 샌드박스 접근법을 모두 설정하고 사용하는 방법을 설명합니다.

Blaxel 설정

설치

  1. blaxel.ai에서 Blaxel 계정을 만듭니다.
  2. 필요한 패키지를 설치합니다.
pip install 'smolagents[blaxel]'

Blaxel로 에이전트 실행: 빠른 시작

Blaxel 샌드박스를 쓰는 방법은 간단합니다. 에이전트 초기화에 executor_type="blaxel"만 추가하면 돼요.

from smolagents import InferenceClientModel, CodeAgent

with CodeAgent(model=InferenceClientModel(), tools=[], executor_type="blaxel") as agent:
    agent.run("Can you give me the 100th Fibonacci number?")

[!TIP] 에이전트를 컨텍스트 매니저로(with 문) 쓰면 작업이 끝나는 즉시 Blaxel 샌드박스가 정리됩니다. 아니면 에이전트의 cleanup() 메서드를 직접 호출해도 돼요.

이 방식은 각 agent.run() 시작 시 에이전트 상태를 서버로 보냅니다. 모델은 로컬 환경에서 호출되지만, 생성된 코드는 샌드박스로 보내져 실행되고 출력만 돌려받습니다.

Blaxel은 최대 25ms 만에 절전 상태에서 깨어나는 빠른 시작 가상 머신을 제공하고, 활동이 없으면 메모리 상태를 유지한 채 0으로 축소됩니다. 빠르고 안전한 코드 실행이 필요한 에이전트 앱에 좋은 선택이에요.

[!TIP] 더 강한 보안 격리가 필요하다면 에이전트 전체를 Blaxel에 원격으로 호스팅할 수도 있습니다. 에이전트, 모델, 툴이 모두 완전히 샌드박싱되죠. 자세한 내용은 Blaxel 에이전트 호스팅 문서를 참고하세요.

E2B 설정

설치

  1. e2b.dev에서 E2B 계정을 만듭니다.
  2. 필요한 패키지를 설치합니다.
pip install 'smolagents[e2b]'

E2B에서 에이전트 실행: 빠른 시작

E2B 샌드박스를 쓰는 방법도 간단합니다. 에이전트 초기화에 executor_type="e2b"만 추가하면 돼요.

from smolagents import InferenceClientModel, CodeAgent

with CodeAgent(model=InferenceClientModel(), tools=[], executor_type="e2b") as agent:
    agent.run("Can you give me the 100th Fibonacci number?")

[!TIP] 에이전트를 컨텍스트 매니저로 쓰면 작업이 끝나는 즉시 E2B 샌드박스가 정리됩니다. 아니면 cleanup() 메서드를 직접 호출해도 돼요.

이 방식은 각 agent.run() 시작 시 에이전트 상태를 서버로 보냅니다. 모델은 로컬 환경에서 호출되지만, 생성된 코드는 샌드박스로 보내져 실행되고 출력만 돌려받습니다.

아래 그림이 이 구조를 보여줍니다.

sandboxed code execution

다만, 매니지드 에이전트 호출은 모델 호출을 필요로 하는데, 원격 샌드박스로 시크릿을 전송하지 않기 때문에 모델 호출에 자격 증명이 없다는 문제가 있어요. 그래서 이 방식은 (아직) 더 복잡한 멀티 에이전트 구성에서는 동작하지 않습니다.

E2B에서 에이전트 실행: 멀티 에이전트

E2B 샌드박스에서 멀티 에이전트를 쓰려면 에이전트를 E2B 안에서 완전히 실행해야 합니다.

방법은 이렇습니다.

from e2b_code_interpreter import Sandbox
import os

# Create the sandbox
sandbox = Sandbox()

# Install required packages
sandbox.commands.run("pip install smolagents")

def run_code_raise_errors(sandbox, code: str, verbose: bool = False) -> str:
    execution = sandbox.run_code(
        code,
        envs={'HF_TOKEN': os.getenv('HF_TOKEN')}
    )
    if execution.error:
        execution_logs = "\n".join([str(log) for log in execution.logs.stdout])
        logs = execution_logs
        logs += execution.error.traceback
        raise ValueError(logs)
    return "\n".join([str(log) for log in execution.logs.stdout])

# Define your agent application
agent_code = """
import os
from smolagents import CodeAgent, InferenceClientModel

# Initialize the agents
agent = CodeAgent(
    model=InferenceClientModel(token=os.getenv("HF_TOKEN"), provider="together"),
    tools=[],
    name="coder_agent",
    description="This agent takes care of your difficult algorithmic problems using code."
)

manager_agent = CodeAgent(
    model=InferenceClientModel(token=os.getenv("HF_TOKEN"), provider="together"),
    tools=[],
    managed_agents=[agent],
)

# Run the agent
response = manager_agent.run("What's the 20th Fibonacci number?")
print(response)
"""

# Run the agent code in the sandbox
execution_logs = run_code_raise_errors(sandbox, agent_code)
print(execution_logs)

설치

  1. modal.com에서 Modal 계정을 만듭니다.
  2. 필요한 패키지를 설치합니다.
pip install 'smolagents[modal]'

Modal에서 에이전트 실행: 빠른 시작

Modal 샌드박스를 쓰는 방법도 같습니다. executor_type="modal"만 추가하면 돼요.

from smolagents import InferenceClientModel, CodeAgent

with CodeAgent(model=InferenceClientModel(), tools=[], executor_type="modal") as agent:
    agent.run("What is the 42th Fibonacci number?")

[!TIP] 에이전트를 컨텍스트 매니저로 쓰면 작업이 끝나는 즉시 Modal 샌드박스가 정리됩니다. 아니면 cleanup() 메서드를 직접 호출해도 돼요.

InferenceClientModel의 에이전트 상태와 생성된 코드가 Modal 샌드박스로 보내지고, 그 안에서 코드를 안전하게 실행합니다.

Docker 설정

설치

  1. 시스템에 Docker를 설치합니다.
  2. 필요한 패키지를 설치합니다.
pip install 'smolagents[docker]'

Docker에서 에이전트 실행: 빠른 시작

E2B 샌드박스와 비슷하게, Docker도 executor_type="docker"만 추가하면 빠르게 시작할 수 있어요.

from smolagents import InferenceClientModel, CodeAgent

with CodeAgent(model=InferenceClientModel(), tools=[], executor_type="docker") as agent:
    agent.run("Can you give me the 100th Fibonacci number?")

[!TIP] 에이전트를 컨텍스트 매니저로 쓰면 작업이 끝나는 즉시 Docker 컨테이너가 정리됩니다. 아니면 cleanup() 메서드를 직접 호출해도 돼요.

고급 Docker 사용법

Docker에서 멀티 에이전트 시스템을 실행하려면 샌드박스에 커스텀 인터프리터를 설정해야 합니다.

Dockerfile을 이렇게 만들 수 있어요.

FROM python:3.10-bullseye

# Install build dependencies
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        build-essential \
        python3-dev && \
    pip install --no-cache-dir --upgrade pip && \
    pip install --no-cache-dir smolagents && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# Set working directory
WORKDIR /app

# Run with limited privileges
USER nobody

# Default command
CMD ["python", "-c", "print('Container ready')"]

그다음 코드를 실행할 샌드박스 매니저를 만듭니다.

import docker
import os
from typing import Optional

class DockerSandbox:
    def __init__(self):
        self.client = docker.from_env()
        self.container = None

    def create_container(self):
        try:
            image, build_logs = self.client.images.build(
                path=".",
                tag="agent-sandbox",
                rm=True,
                forcerm=True,
                buildargs={},
                # decode=True
            )
        except docker.errors.BuildError as e:
            print("Build error logs:")
            for log in e.build_log:
                if 'stream' in log:
                    print(log['stream'].strip())
            raise

        # Create container with security constraints and proper logging
        self.container = self.client.containers.run(
            "agent-sandbox",
            command="tail -f /dev/null",  # Keep container running
            detach=True,
            tty=True,
            mem_limit="512m",
            cpu_quota=50000,
            pids_limit=100,
            security_opt=["no-new-privileges"],
            cap_drop=["ALL"],
            environment={
                "HF_TOKEN": os.getenv("HF_TOKEN")
            },
        )

    def run_code(self, code: str) -> Optional[str]:
        if not self.container:
            self.create_container()

        # Execute code in container
        exec_result = self.container.exec_run(
            cmd=["python", "-c", code],
            user="nobody"
        )

        # Collect all output
        return exec_result.output.decode() if exec_result.output else None


    def cleanup(self):
        if self.container:
            try:
                self.container.stop()
            except docker.errors.NotFound:
                # Container already removed, this is expected
                pass
            except Exception as e:
                print(f"Error during cleanup: {e}")
            finally:
                self.container = None  # Clear the reference

# Example usage:
sandbox = DockerSandbox()

try:
    # Define your agent code
    agent_code = """
import os
from smolagents import CodeAgent, InferenceClientModel

# Initialize the agent
agent = CodeAgent(
    model=InferenceClientModel(token=os.getenv("HF_TOKEN"), provider="together"),
    tools=[]
)

# Run the agent
response = agent.run("What's the 20th Fibonacci number?")
print(response)
"""

    # Run the code in the sandbox
    output = sandbox.run_code(agent_code)
    print(output)

finally:
    sandbox.cleanup()

샌드박스 모범 사례

이 핵심 사례들은 Blaxel, E2B, Docker 샌드박스에 모두 적용됩니다.

  • 리소스 관리
    • 메모리와 CPU 한도를 설정한다
    • 실행 타임아웃을 구현한다
    • 리소스 사용량을 모니터링한다
  • 보안
    • 최소 권한으로 실행한다
    • 불필요한 네트워크 접근을 차단한다
    • 시크릿에는 환경 변수를 사용한다
  • 환경
    • 의존성을 최소로 유지한다
    • 고정 패키지 버전을 사용한다
    • 베이스 이미지를 쓴다면 주기적으로 갱신한다
  • 정리
    • 특히 Docker 컨테이너는 리소스가 제대로 정리되도록 항상 확인한다. 걸림 없는 컨테이너가 리소스를 잡아먹는 것을 피하기 위해서예요.

✨ 이러한 사례를 따르고 적절한 정리 절차를 갖추면, 에이전트가 샌드박스 환경에서 안전하고 효율적으로 실행되도록 보장할 수 있어요.

보안 접근법 비교

앞선 그림에서 봤듯이, 두 샌드박싱 접근법은 보안 영향이 다릅니다.

접근법 1: 코드 조각만 샌드박스에서 실행하기

  • 장점
    • 간단한 파라미터 하나로 쉽게 설정 가능 (executor_type="blaxel", executor_type="e2b", executor_type="docker")
    • API 키를 샌드박스로 옮길 필요 없음
    • 로컬 환경 보호가 더 좋음
    • Blaxel의 하이버네이션 기술(<25ms 시작)로 빠른 실행 가능
  • 단점
    • 멀티 에이전트(매니지드 에이전트) 미지원
    • 환경과 샌드박스 사이에 상태를 전달해야 함
    • 특정 코드 실행에만 국한됨

접근법 2: 에이전트 시스템 전체를 샌드박스에서 실행하기

  • 장점
    • 멀티 에이전트 지원
    • 에이전트 시스템 전체의 완전한 격리
    • 복잡한 에이전트 아키텍처에 더 유연함
  • 단점
    • 수동 설정이 더 필요
    • 민감한 API 키를 샌드박스로 전달해야 할 수 있음
    • 더 복잡한 연산 때문에 잠재적으로 더 높은 지연 시간

보안 요구와 애플리케이션 요구 사이의 균형을 가장 잘 맞추는 접근법을 고르세요. 대부분의 단순한 에이전트 아키텍처에는 접근법 1이 보안과 사용 용이성을 잘 균형 잡아 줍니다. 전체 격리가 필요한 더 복잡한 멀티 에이전트 시스템에는 접근법 2가, 설정은 더 번거롭지만 더 나은 보안을 보장합니다.

더 알아보기 (Learn more)