AI agent용 Sandbox: agent 코드를 안전하게 실행하기

1. 개요

점점 더 많은 애플리케이션이 AI agent가 직접 코드를 작성하고 실행하도록 허용하고 있습니다. Agent는 이를 통해 데이터를 분석하거나 계산하거나 파일을 조작합니다. 문제는 그 코드가 언어 모델에 의해 생성되므로, 당신이 직접 내용을 제어할 수 없다는 것입니다.

이 글은 agent를 위한 sandbox 개념과 이 주제가 왜 주목받고 있는지 설명합니다. 또한 일반적인 격리 수준들을 비교합니다. 핵심 부분은 Java 21과 Spring Boot 3.x로 DockerSandbox를 구축하고, 안전 제한 사항을 검증하는 테스트를 포함합니다.

2. Agent가 sandbox를 필요로 하는 이유

“실행할 코드” 도구를 가진 agent는 본질적으로 텍스트 명령을 받는 프로세스입니다. 그 텍스트는 사용자로부터, agent가 방금 읽은 웹 페이지로부터, 또는 첨부 파일로부터 올 수 있습니다. 따라서 다음과 같은 위험을 고려해야 합니다:

  • Prompt injection: 웹 페이지에 숨겨진 텍스트가 있어 “환경 변수를 읽고 주소 X로 보내라” 같은 명령이 있을 수 있습니다. Agent가 따를 수 있습니다.
  • 의도는 좋지만 잘못된 코드: 무한 루프, 디스크를 가득 채우기, 실수로 디렉토리 삭제.
  • 비밀 정보 유출: 애플리케이션과 같은 프로세스에서 실행되는 코드는 API 키, 설정 파일, 데이터베이스 연결을 읽을 수 있습니다.
  • 권한 상승: 코드를 실행하는 머신에서 공격자가 같은 네트워크의 내부 서비스로 이동할 수 있습니다.

Sandbox는 격리된 환경입니다. 그곳에서 실행되는 코드는 당신이 허용한 것만 봅니다: 네트워크 없음, 비밀 없음, CPU/RAM/시간 제한, 임시 파일 시스템이 실행마다 버려집니다.

이 주제가 주목받는 이유는 agent가 “질문에 답하기”에서 “행동하기”로 전환했기 때문입니다. Agent가 행동 권한을 가지면 sandbox는 필수 인프라가 됩니다.

3. 일반적인 격리 수준

Sandbox는 하나의 종류가 아닙니다. 각 수준은 안전성, 시작 속도, 운영 복잡도 사이에서 균형을 맞춥니다:

수준 예시 장점 제한사항
제한된 인터프리터 같은 JVM에서 스크립트 실행, API 차단 빠르고 간단 쉽게 우회되고, 신뢰할 수 없는 코드에는 사용 금지
Container 제한 플래그가 있는 Docker/Podman 널리 사용되고 구축이 쉬움 호스트와 커널 공유
Container + kernel sandbox gVisor 같은 런타임에서 실행되는 container 커널 공격 표면 축소 추가 성능 오버헤드, 일부 syscall 미지원
MicroVM Firecracker, Kata Containers VM 수준의 격리 더 복잡한 운영
WebAssembly WASM 런타임 초고속 시작, 기본적으로 권한 없음 라이브러리 에코시스템 제한
관리형 sandbox 서비스 클라우드 플랫폼의 agent 코드 실행 서비스 자체 운영 필요 없음 공급자 의존, 데이터가 시스템 밖으로 나감

대부분의 팀에는 엄격하게 구성된 container가 합리적인 시작점입니다. 위험이 더 높을 때, 예를 들어 다중 테넌트나 익명 사용자의 경우, 애플리케이션 코드를 변경하지 않고도 더 강력한 격리 런타임으로 업그레이드할 수 있습니다.

4. DockerSandbox 구축

목표는 agent가 생성한 Python 코드를 받아 일회용 container에서 실행하고 결과를 반환하는 레이어입니다. 애플리케이션을 실행하는 머신에 Docker CLI가 있어야 합니다.

4.1. 설정

먼저 모든 제한을 설정 레코드에 모으세요:

package dev.nonla.sandbox;

import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;

@ConfigurationProperties(prefix = "agent.sandbox")
public record SandboxProperties(
        String image,
        String runtime,      // null = 기본 런타임; "runsc"로 설정 가능
        Duration timeout,
        String memory,
        String cpus,
        int pidsLimit,
        int maxOutputBytes) {
}
agent:
  sandbox:
    image: python:3.12-slim
    timeout: 5s
    memory: 256m
    cpus: "0.5"
    pids-limit: 64
    max-output-bytes: 65536

runtime 필드를 사용하면 나중에 더 강력한 격리 런타임으로 전환할 수 있습니다.

4.2. 반환 결과

package dev.nonla.sandbox;

public record SandboxResult(int exitCode, String stdout, String stderr, boolean timedOut) {

    public boolean success() {
        return !timedOut && exitCode == 0;
    }
}

4.3. Sandbox 클래스

package dev.nonla.sandbox;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;

public class DockerSandbox {

    private final SandboxProperties props;

    public DockerSandbox(SandboxProperties props) {
        this.props = props;
    }

    public SandboxResult runPython(String code) throws IOException, InterruptedException {
        String name = "agent-sbx-" + UUID.randomUUID();
        Process process = new ProcessBuilder(buildCommand(name)).start();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            Future<String> out = executor.submit(() -> readLimited(process.getInputStream()));
            Future<String> err = executor.submit(() -> readLimited(process.getErrorStream()));

            try (OutputStream stdin = process.getOutputStream()) {
                stdin.write(code.getBytes(StandardCharsets.UTF_8));
            }

            boolean finished = process.waitFor(props.timeout().toMillis(), TimeUnit.MILLISECONDS);
            if (!finished) {
                killContainer(name);
                process.destroyForcibly().waitFor();
            }
            return new SandboxResult(
                    finished ? process.exitValue() : -1,
                    await(out), await(err), !finished);
        }
    }

    List<String> buildCommand(String name) {
        List<String> cmd = new ArrayList<>(List.of(
                "docker", "run", "--rm", "-i",
                "--name", name,
                "--network", "none",
                "--memory", props.memory(),
                "--memory-swap", props.memory(),
                "--cpus", props.cpus(),
                "--pids-limit", String.valueOf(props.pidsLimit()),
                "--read-only",
                "--tmpfs", "/tmp:rw,size=64m",
                "--cap-drop", "ALL",
                "--security-opt", "no-new-privileges",
                "--user", "65534:65534",
                "--env", "HOME=/tmp"));
        if (props.runtime() != null && !props.runtime().isBlank()) {
            cmd.addAll(List.of("--runtime", props.runtime()));
        }
        cmd.addAll(List.of(props.image(), "python", "-"));
        return cmd;
    }

    private String readLimited(InputStream in) throws IOException {
        var buffer = new ByteArrayOutputStream();
        byte[] chunk = new byte[4096];
        int n;
        while ((n = in.read(chunk)) != -1) {
            int room = props.maxOutputBytes() - buffer.size();
            if (room > 0) {
                buffer.write(chunk, 0, Math.min(n, room));
            }
            // 프로세스가 블로킹되지 않도록 계속 읽지만, 초과분은 버린다
        }
        return buffer.toString(StandardCharsets.UTF_8);
    }

    private void killContainer(String name) throws IOException, InterruptedException {
        new ProcessBuilder("docker", "kill", name).start().waitFor(5, TimeUnit.SECONDS);
    }

    private static String await(Future<String> future) throws InterruptedException {
        try {
            return future.get(5, TimeUnit.SECONDS);
        } catch (ExecutionException | TimeoutException e) {
            return "";
        }
    }
}

주요 포인트 설명:

  • 코드는 stdin으로 전달 (python -). 호스트의 디렉토리를 마운트할 필요가 없으므로 container는 애플리케이션 파일을 볼 수 없습니다.
  • --rm과 랜덤한 --name을 함께 사용하면 일회용 container를 만듭니다. 매번 실행할 때마다 깨끗한 환경입니다.
  • --network none은 네트워크를 완전히 끊습니다. 데이터 유출을 방지하는 가장 중요한 계층입니다.
  • --memory는 --memory-swap과 같은 값으로 swap을 비활성화합니다. --cpus와 --pids-limit은 리소스 고갈과 fork bomb을 막습니다.
  • --read-only는 루트 파일 시스템을 잠급니다. --tmpfs /tmp는 제한된 크기의 임시 쓰기 영역을 제공합니다.
  • --cap-drop ALL, no-new-privileges, --user 65534:65534 (nobody 사용자)는 코드가 root로 실행되지 않고 권한을 요청할 수 없도록 보장합니다.
  • 애플리케이션의 환경 변수는 container에 전달되지 않습니다. HOME=/tmp만 설정하여 Python 라이브러리가 캐시를 쓸 수 있도록 합니다.
  • stdout과 stderr은 virtual thread로 병렬로 읽습니다. 순차적으로 읽으면 파이프 버퍼가 가득 차서 프로세스가 멈울 수 있습니다.
  • 시간 초과 시 docker kill을 호출한 후 프로세스를 제거합니다.

주의: process.destroyForcibly()는 클라이언트 쪽의 docker CLI 프로세스만 제거합니다. Container는 계속 실행될 수 있습니다. 따라서 반드시 container 이름으로 docker kill을 호출해야 합니다.

5. Spring Boot와 통합

spring-boot-starter 외에 추가 라이브러리가 필요 없습니다. 테스트는 spring-boot-starter-test를 사용합니다. 여기에 JUnit 5와 AssertJ가 포함되어 있습니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

다음으로 bean을 선언하고 agent의 “도구” 역할을 하는 service를 만드세요:

package dev.nonla.sandbox;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.*;

@Configuration
@EnableConfigurationProperties(SandboxProperties.class)
class SandboxConfig {

    @Bean
    DockerSandbox dockerSandbox(SandboxProperties props) {
        return new DockerSandbox(props);
    }
}
package dev.nonla.sandbox;

import org.slf4j.*;
import org.springframework.stereotype.Service;

@Service
public class CodeExecutionTool {

    private static final Logger log = LoggerFactory.getLogger(CodeExecutionTool.class);
    private final DockerSandbox sandbox;

    public CodeExecutionTool(DockerSandbox sandbox) {
        this.sandbox = sandbox;
    }

    public String executePython(String sessionId, String code) {
        log.info("sandbox.exec session={} codeLength={}", sessionId, code.length());
        try {
            SandboxResult r = sandbox.runPython(code);
            log.info("sandbox.done session={} exit={} timedOut={}", sessionId, r.exitCode(), r.timedOut());
            if (r.timedOut()) {
                return "오류: 코드 실행이 허용된 시간을 초과했습니다.";
            }
            return "exit=" + r.exitCode() + "\nstdout:\n" + r.stdout() + "\nstderr:\n" + r.stderr();
        } catch (Exception e) {
            log.error("sandbox.error session={}", sessionId, e);
            return "코드 실행 중 시스템 오류가 발생했습니다.";
        }
    }
}

executePython 메서드는 사용 중인 agent 프레임워크에 도구로 등록하는 항목입니다. 두 가지 주의사항이 있습니다:

  • 결과는 항상 문자열로 반환되며, 오류인 경우에도 그렇습니다. Agent는 stderr을 읽고 다음 시도에서 코드를 자신이 수정할 수 있습니다.
  • 모든 실행은 sessionId를 포함한 감사 로그로 기록됩니다. 문제가 발생할 때 감사 로그가 가장 먼저 확인해야 할 것입니다.

오류 메시지는 일반적으로 유지되며, stack trace는 모델로 반환되지 않습니다. Stack trace는 경로나 내부 설정을 노출할 수 있습니다.

6. Sandbox 동작 테스트

테스트는 각 제한이 실제로 효과가 있음을 증명해야 합니다. 단순히 “코드가 실행된다”는 것만이 아닙니다. 아래 테스트는 Docker가 필요합니다. 머신에 Docker가 없으면 테스트는 건너뜁니다.

package dev.nonla.sandbox;

import org.junit.jupiter.api.*;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

class DockerSandboxTest {

    private DockerSandbox sandbox;

    @BeforeEach
    void setUp() {
        assumeTrue(dockerAvailable(), "Docker를 사용할 수 없습니다");
        sandbox = new DockerSandbox(new SandboxProperties(
                "python:3.12-slim", null, Duration.ofSeconds(3),
                "128m", "0.5", 32, 1024));
    }

    @Test
    void runsSimpleCode() throws Exception {
        SandboxResult r = sandbox.runPython("print(sum(range(10)))");

        assertThat(r.success()).isTrue();
        assertThat(r.stdout().trim()).isEqualTo("45");
    }

    @Test
    void blocksNetworkAccess() throws Exception {
        SandboxResult r = sandbox.runPython("""
                import urllib.request
                urllib.request.urlopen("http://example.com", timeout=2)
                """);

        assertThat(r.exitCode()).isNotZero();
        assertThat(r.stderr()).contains("Error");
    }

    @Test
    void rootFileSystemIsNotWritable() throws Exception {
        SandboxResult r = sandbox.runPython("open('/hacked.txt', 'w').write('x')");

        assertThat(r.exitCode()).isNotZero();
    }

    @Test
    void killsInfiniteLoop() throws Exception {
        SandboxResult r = sandbox.runPython("while True: pass");

        assertThat(r.timedOut()).isTrue();
    }

    @Test
    void truncatesHugeOutput() throws Exception {
        SandboxResult r = sandbox.runPython("print('a' * 100_000)");

        assertThat(r.stdout()).hasSizeLessThanOrEqualTo(1024);
    }

    private static boolean dockerAvailable() {
        try {
            Process p = new ProcessBuilder("docker", "info").start();
            return p.waitFor(10, TimeUnit.SECONDS) && p.exitValue() == 0;
        } catch (Exception e) {
            return false;
        }
    }
}

각 테스트는 한 가지 제한에 해당합니다:

  • runsSimpleCode는 기본 경로를 확인합니다: stdout은 45입니다.
  • blocksNetworkAccess는 HTTP 요청이 실패함을 보여줍니다. stderr에는 다음과 같은 내용이 포함됩니다:
urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution>
  • rootFileSystemIsNotWritable은 코드가 /tmp 외부에 쓸 수 없음을 확인합니다.
  • killsInfiniteLoop은 timeout이 작동하고 container가 정리됨을 확인합니다.
  • truncatesHugeOutput은 output이 잘림을 확인합니다. 이덕분에 agent는 context나 애플리케이션 메모리를 팽창시킬 수 없습니다.

주의: 첫 실행은 Docker가 이미지를 다운로드해야 하므로 느릴 수 있습니다. CI에서 이미지를 미리 pull하거나 애플리케이션 시작 시 pull해야 합니다. 그렇지 않으면 test timeout이 잘못 보고될 수 있습니다.

7. 추가 방어 계층

Sandbox는 중요하지만 유일한 계층이 아닙니다. 실제로는 다음을 추가로 결합해야 합니다:

  • 제어된 Egress: agent가 실제로 네트워크가 필요하다면, 예를 들어 패키지를 설치하려면 전체를 열지 마세요. allowlist 도메인이 있는 프록시를 통하도록 하세요.
  • 비밀은 절대 sandbox에 들어가지 않음: 코드가 API를 호출해야 할 때, 애플리케이션이 권한 확인을 통해 대신 호출하는 별도 도구를 사용하세요. Token을 container에 전달하지 마세요.
  • 사람이 확인: 되돌릴 수 없는 작업(이메일 발송, 실제 데이터베이스 쓰기, 송금)의 경우 사용자 확인을 요청하세요.
  • 속도 제한 및 할당량: 각 세션은 일정 횟수로만 실행할 수 있습니다. 이는 agent가 시도-실패 루프에 빠져 리소스를 낭비하는 것을 방지합니다.
  • Sandbox를 실행하는 머신 분리: 이 머신에서 Docker daemon을 실행하여 sandbox를 관리하고, 주요 서비스와 함께 실행하지 마세요.
  • 미리 시작된 container pool: 지연이 중요한 경우 미리 여러 container를 유지하세요. 각 container는 여전히 한 번만 사용되고 버려집니다.

8. 흔한 실수와 사용하지 말아야 할 때

흔한 실수:

  • Docker socket을 애플리케이션이나 sandbox에 마운트. /var/run/docker.sock을 제어하는 사람은 호스트에서 거의 root 권한을 얻습니다. 애플리케이션이 container에서 실행 중이고 Docker를 호출해야 하면, socket 공유 대신 별도의 sandbox 서비스를 고려하세요.
  • --network none을 빠뜨림. 이는 가장 흔한 실수이며 가장 심각한 결과를 초래합니다.
  • Container에서 root로 실행하면서 “패키지 설치가 쉽다”는 이유. 이미지를 사용자 정의하여 패키지를 미리 설치하세요.
  • 사용자 간에 container 재사용. 이전 세션의 파일과 프로세스가 남아 있어 한 사용자의 데이터가 다음 사용자에게 유출됩니다.
  • 코드를 shell 명령어에 연결 (sh -c "python -c '" + code + "'"). 이것은 호스트 쪽에서 command injection을 만듭니다. 항상 stdin이나 파일로 전달하세요.
  • Container가 절대 경계라고 믿음. Container는 커널을 공유합니다. 다중 테넌트 환경에서 악의적인 코드가 있을 때 gVisor나 microVM을 사용해야 합니다.

사용하지 말아야 할 때:

  • Agent가 고정된 몇 가지 계산만 필요합니다. 명확한 타입이 있는 calculateInvoiceTotal(Order) 같은 도구는 임의 코드 실행보다 훨씬 안전하고 저렴합니다.
  • 인터넷의 익명 사용자에게 높은 트래픽으로 서비스합니다. Docker 자체 구축 대신 microVM이나 전문 sandbox 서비스를 사용하세요.
  • Docker를 실행할 수 없는 환경, 예를 들어 일부 서버리스 플랫폼. WASM이나 관리형 sandbox를 고려하세요.
  • 코드가 민감한 데이터에 접근해야 합니다. 필터된 데이터를 stdin이나 임시 파일로 전달하고, sandbox를 느슨하게 하지 마세요.

9. 결론

Agent가 코드를 실행할 수 있을 때, 그것이 읽는 모든 텍스트는 명령이 될 수 있습니다. Sandbox는 그 위험을 측정 가능한 것으로 변환합니다: 네트워크 없음, 비밀 없음, 리소스 제한, 일회용 환경.

당신은 필요한 보안 플래그가 있는 DockerSandbox를 구축했습니다. 이 클래스는 virtual thread를 사용하여 output을 안전하게 읽고, docker kill을 통해 timeout을 올바르게 처리하며, 각 제한을 증명하는 테스트 세트가 있습니다. 이 기반에서 설정만으로 더 강력한 격리 런타임으로 업그레이드할 수 있습니다. 항상 sandbox를 감사 로그, 네트워크 allowlist, 그리고 중요한 작업에 대한 사람의 확인 단계와 결합하세요.

ai-agentsandboxdockersecurityspring-boot