에이전트 작업을 샌드박스에서 실행하기
에이전트 작업을 샌드박스에서 실행하기
에이전트에게 별도 작업 디렉터리를 주는 것만으로는 충분하지 않다. 그 디렉터리에서 시작한 프로세스가 호스트 파일, Docker socket, cloud credential과 외부 네트워크에 접근할 수 있다면 격리된 것처럼 보일 뿐이다. 원본 저장소는 읽기 전용 입력으로 취급하고, 작업별 파일 시스템·프로세스·네트워크·비밀값·CPU와 메모리 한도를 만든 뒤, 최종 산출물을 diff와 검증 결과로만 반출해야 한다.
목차
- #복사본에서 실행하면 충분할까
- #샌드박스가 지켜야 할 경계
- #작업의 생명주기를 먼저 정의한다
- #원본과 작업 디렉터리를 분리한다
- #Worktree는 편집 격리이지 보안 경계는 아니다
- #프로세스와 운영체제 권한을 제한한다
- #CPU 메모리 디스크와 실행 시간을 제한한다
- #네트워크는 기본 차단에서 시작한다
- #비밀값을 이미지와 환경 변수에 넣지 않는다
- #캐시와 의존성 설치도 경계 안에 둔다
- #결과는 Diff와 증거로 반출한다
- #재구성한 Sandbox Runner 예제
- #격리가 깨지는 흔한 경로
- #샌드박스를 테스트하는 방법
- #운영에서 관찰해야 할 지표
- #마무리
- #참고 자료
- #관련 노트
복사본에서 실행하면 충분할까
코딩 에이전트에게 작은 버그 수정을 맡긴다고 하자. 가장 단순한 방식은 현재 개발자가 쓰는 저장소에서 바로 명령을 실행하는 것이다.
사용자 요청
-> 모델이 shell 명령 생성
-> 개발자의 현재 checkout에서 실행
-> 테스트
-> 성공하면 완료
정상 경로만 보면 빠르다. 하지만 에이전트는 코드를 수정하는 동안 다음과 같은 동작도 수행할 수 있다.
- 잘못 계산한 경로의 파일을 삭제한다.
- formatter가 저장소 전체를 바꾼다.
- package install script가 호스트에서 임의 프로세스를 실행한다.
- 테스트가 개발용 DB가 아니라 공유 DB에 접속한다.
- 홈 디렉터리의 설정과 credential을 읽는다.
- 열린 Docker socket을 통해 호스트 수준 컨테이너를 만든다.
- 무한 loop나 fork로 CPU와 memory를 소진한다.
- 실패 후 재시도하며 동일한 외부 API를 반복 호출한다.
# 예시일 뿐이며 이런 형태로 실행해서는 안 된다.
cd "$DEVELOPER_WORKSPACE"
npm install
npm test
여기에는 세 가지 신뢰가 한꺼번에 섞여 있다.
- 모델이 올바른 명령을 만들 것이라는 신뢰
- 저장소의 코드와 의존성이 안전하다는 신뢰
- 명령이 호스트의 나머지 자원에 영향을 주지 않을 것이라는 신뢰
첫 번째를 아무리 개선해도 두 번째와 세 번째는 사라지지 않는다. 특히 외부 저장소, pull request, issue 본문과 문서에는 에이전트를 속이는 문자열이 들어갈 수 있다. 샌드박스는 이 모든 입력을 신뢰하지 않아도 작업을 실행할 수 있게 만드는 경계다.
악의적인 코드도 안전하게 실행한다는 절대적인 보장이 아니다. 실행이 탈출하거나 오작동할 가능성을 전제로, 접근 가능한 자원과 지속 시간, 반출 가능한 결과를 최소화해 피해 범위를 줄이는 것이다.
샌드박스가 지켜야 할 경계
/tmp/task-123 폴더를 만들고 거기서 명령을 실행하면 편집 충돌은 줄어든다. 그러나 shell이 같은 사용자 권한으로 실행된다면 /tmp 밖의 파일에도 접근할 수 있다. 폴더 분리와 보안 격리를 구분해야 한다.
| 경계 | 제한할 대상 | 제한하지 않으면 생기는 일 |
|---|---|---|
| 파일 시스템 | 원본 저장소, 홈, socket, device | 원본 훼손, credential 탈취 |
| 프로세스 | PID, syscall, 사용자 권한 | 호스트 프로세스 관찰·공격 |
| 네트워크 | DNS, IP, port, redirect | 데이터 유출, 내부망 접근 |
| 자원 | CPU, memory, PID 수, disk | 호스트 전체 서비스 저하 |
| 시간 | 실행·유휴·전체 작업 timeout | 좀비 작업과 비용 누적 |
| 비밀값 | scope, lifetime, audience | 장기 credential 재사용 |
| 산출물 | 파일 수·크기·형식 | 악성 binary와 대용량 반출 |
flowchart TB
U[사용자 요청] --> O[Orchestrator]
O --> S[작업별 Sandbox]
subgraph S[격리 경계]
W[쓰기 가능한 작업 복사본]
P[제한된 프로세스]
N[제한된 네트워크]
C[작업 전용 자격 증명]
R[CPU·메모리·디스크 한도]
end
S --> A[Diff·테스트 결과·로그]
A --> G{정책 검사와 승인}
G -- 승인 --> M[원본에 반영]
G -- 거부 --> D[샌드박스 폐기]중요한 점은 원본 저장소가 샌드박스의 쓰기 대상이 아니라 입력이라는 것이다. 샌드박스 밖으로 나가는 것도 임의 파일 복사가 아니라 정책을 통과한 patch와 검증 증거여야 한다.
작업의 생명주기를 먼저 정의한다
격리 기술부터 고르면 생성과 정리 사이의 상태가 빠지기 쉽다. 먼저 작업을 상태 머신으로 표현하면 어느 단계에서 어떤 권한이 필요한지 보인다.
stateDiagram-v2
[*] --> Requested
Requested --> Provisioning: 정책 검사
Provisioning --> Ready: sandbox 생성
Ready --> Running: 명령 실행
Running --> Verifying: 변경 완료
Running --> Failed: timeout 또는 위반
Verifying --> AwaitingApproval: 검사 통과
Verifying --> Failed: 검사 실패
AwaitingApproval --> Applied: 승인
AwaitingApproval --> Discarded: 거부 또는 만료
Applied --> Destroyed: 결과 보존 후 정리
Failed --> Destroyed: 로그 보존 후 정리
Discarded --> Destroyed
Destroyed --> [*]각 작업에는 최소한 다음 식별자가 있어야 한다.
type SandboxTask = {
taskId: string;
actorId: string;
repositoryId: string;
baseRevision: string;
policyVersion: string;
createdAt: string;
expiresAt: string;
};
baseRevision은 특히 중요하다. 에이전트가 변경을 만든 뒤 원본 branch가 이동했다면, 이전 상태를 기준으로 승인한 diff를 그대로 적용해서는 안 된다. 승인 시점에 기준 revision과 현재 revision을 대조하고 달라졌다면 다시 rebase하고 검증해야 한다.
“작업이 끝나면 지운다”는 운영자의 기억이 아니다. 성공·실패·취소·만료 모든 종단 상태가 credential 폐기와 sandbox 파괴로 이어지도록 상태 전이를 구현한다.
원본과 작업 디렉터리를 분리한다
작업 복사본을 만드는 방식은 저장소 크기와 위협 모델에 따라 다르다.
| 방식 | 장점 | 주의점 | 적합한 경우 |
|---|---|---|---|
| 전체 clone | 원본과 Git metadata까지 분리 | 생성과 fetch 비용 | 신뢰가 낮은 원격 실행 |
| Git worktree | 빠르고 object 공유 | .git 공용 영역과 ref 공유 |
신뢰된 로컬 편집 격리 |
| snapshot + overlay | 빠른 생성, 변경 layer 분리 | 별도 인프라 필요 | 대량의 일회성 작업 |
| archive export | .git 없이 최소 입력 |
Git 명령 제약 | 빌드·분석 전용 작업 |
가장 강한 기본형은 특정 commit을 독립된 실행 환경에 checkout하고 원본 remote에 write credential을 제공하지 않는 것이다.
# 재구성한 흐름: 값은 실행기가 검증한 뒤 전달한다고 가정한다.
git init /workspace/repo
git -C /workspace/repo remote add origin "$READ_ONLY_REPOSITORY_URL"
git -C /workspace/repo fetch --depth=1 origin "$BASE_REVISION"
git -C /workspace/repo checkout --detach FETCH_HEAD
에이전트가 git push를 호출하더라도 remote credential이 없고 network policy도 Git host 쓰기를 허용하지 않으면 원본에 반영되지 않는다. 변경은 별도 단계에서 patch로 꺼낸다.
git -C /workspace/repo diff --binary --no-ext-diff "$BASE_REVISION" > /output/change.patch
git -C /workspace/repo status --porcelain=v1 -z > /output/status.bin
--binary는 binary 변경을 무조건 허용한다는 뜻이 아니다. 변경을 빠뜨리지 않고 수집한 뒤, 반출 정책이 확장자·MIME·크기와 경로를 검사해야 한다.
Worktree는 편집 격리이지 보안 경계는 아니다
Git worktree는 한 저장소에서 별도 HEAD와 index를 가진 작업 트리를 빠르게 만들 수 있어 동시 작업에 유용하다. 하지만 object database와 많은 ref가 공용 Git directory에 연결된다. 같은 호스트 사용자 권한으로 실행한다면 보안 sandbox가 아니다.
git worktree add --detach /var/tmp/agent-task-a1b2 "$BASE_REVISION"
이 구조는 다음 문제는 잘 해결한다.
- 개발자가 수정 중인 working tree와 파일 충돌을 피한다.
- 작업마다 독립된 index와
HEAD를 가진다. - 전체 clone보다 빠르게 작업 공간을 만든다.
- diff와 테스트를 작업별로 관리하기 쉽다.
반면 다음은 별도 통제가 필요하다.
- 같은 사용자에게 보이는 홈 디렉터리
- 공용
.git영역과 local hook/config - 호스트의 network와 process
- 환경 변수와 SSH agent socket
- 다른 worktree와 서비스의 파일
worktree만 사용
= 변경 충돌 격리
!= 보안 실행 격리
worktree + container/VM + 최소 권한
= 편집 격리 + 실행 경계
실용적으로는 신뢰된 개인 저장소의 로컬 보조 작업에는 worktree가 좋은 선택이다. 외부 pull request나 의존성 script를 실행한다면 container, microVM 또는 별도 host 같은 강한 경계를 추가해야 한다.
프로세스와 운영체제 권한을 제한한다
Linux 기반 실행 환경에서는 여러 격리 장치를 겹쳐 사용한다. 하나의 장치가 모든 위험을 해결하지 않는다.
namespace -> 보이는 PID·mount·network·user 범위를 분리
cgroup -> CPU·memory·PID·I/O 사용량 제한
seccomp -> 허용할 system call 제한
capability -> root 권한을 세부 기능으로 분해
LSM -> AppArmor·SELinux 등으로 객체 접근 정책 강제
read-only -> root filesystem 변경 차단
컨테이너를 사용한다면 root user, privileged mode, host namespace를 피하는 것이 출발점이다.
# 개념 전달용 예시이며 실행 환경에 맞게 조정해야 한다.
services:
agent-task:
image: example.invalid/agent-runner:2026-05
user: "10001:10001"
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 128
tmpfs:
- /tmp:size=256m,noexec,nosuid,nodev
volumes:
- task-workspace:/workspace:rw
- task-output:/output:rw
read_only: true여도 /workspace와 /output은 의도적으로 writable하다. 어느 mount가 writable인지 목록으로 관리하고, host path를 직접 bind mount한다면 source path를 검증한다.
/var/run/docker.sock, container runtime socket, host root, 사용자 홈과 SSH agent socket은 샌드박스 탈출에 가까운 권한을 제공할 수 있다. 단지 편리하다는 이유로 작업 환경에 넘기지 않는다.
컨테이너는 kernel을 공유한다. 더 높은 격리가 필요하거나 서로 신뢰하지 않는 tenant가 함께 실행된다면 별도 VM이나 microVM, 전용 worker pool을 고려한다. 선택은 “container냐 VM이냐”라는 취향이 아니라 입력의 신뢰도와 탈출 시 피해 규모로 결정한다.
CPU 메모리 디스크와 실행 시간을 제한한다
악의적인 명령만 위험한 것은 아니다. 평범한 빌드도 잘못된 설정으로 모든 CPU를 사용하거나 메모리를 소진한다.
type ResourceLimits = {
cpuCores: number;
memoryMb: number;
pids: number;
writableDiskMb: number;
commandTimeoutSeconds: number;
taskTimeoutSeconds: number;
maxOutputBytes: number;
};
const defaultLimits: ResourceLimits = {
cpuCores: 2,
memoryMb: 4096,
pids: 128,
writableDiskMb: 8192,
commandTimeoutSeconds: 600,
taskTimeoutSeconds: 1800,
maxOutputBytes: 10 * 1024 * 1024,
};
명령 timeout과 전체 작업 timeout을 구분한다. 명령 하나가 10분 안에 끝나도 에이전트가 명령을 계속 생성하면 작업이 끝나지 않을 수 있다. 유휴 timeout도 있으면 model이나 runner 연결이 끊긴 좀비 작업을 정리할 수 있다.
| 한도 | 초과 시 동작 | 남길 증거 |
|---|---|---|
| command timeout | process group 종료 | command ID, elapsed time |
| task timeout | 새 실행 거부 후 전체 종료 | 마지막 상태, 수행 명령 수 |
| memory | OOM 종료 | peak memory, exit reason |
| PID | 새 process 생성 거부 | process count |
| disk | write 실패 후 작업 중단 | 사용량, 큰 파일 목록 |
| output | 이후 출력 truncate | 원래 byte 수, checksum |
프로세스 하나만 종료하면 child process가 남을 수 있다. 각 명령을 task cgroup 또는 process group에 넣고 종료할 때 group 전체를 정리해야 한다.
네트워크는 기본 차단에서 시작한다
많은 빌드가 package registry를 필요로 한다는 이유로 unrestricted egress를 열기 쉽다. 그러면 저장소에서 읽은 secret이나 source를 어느 서버로든 전송할 수 있다.
나쁜 기본값
outbound: allow all
blocked: known bad hosts
더 안전한 기본값
outbound: deny all
allowed: 작업 목적에 필요한 proxy와 registry
단순 도메인 allowlist도 충분하지 않다. DNS 응답, redirect, proxy, IPv4/IPv6와 private address 접근을 함께 다뤄야 한다.
networkPolicy:
default: deny
dns:
resolver: sandbox-dns
allow:
- purpose: npm-packages
proxy: https://package-proxy.example.invalid
methods: [GET, HEAD]
- purpose: source-read
host: git-read.example.invalid
port: 443
denyCidrs:
- 127.0.0.0/8
- 169.254.0.0/16
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
예시의 CIDR만 복사해 완전한 정책이라고 생각하면 안 된다. IPv6, cloud metadata endpoint, 조직 내부 대역과 DNS rebinding도 환경에 맞게 막아야 한다. 가능하면 sandbox가 인터넷으로 직접 나가지 않고 정책과 감사가 가능한 egress proxy를 거치게 한다.
의존성을 준비하는 단계만 검증된 package proxy에 접근시키고, 테스트 실행 단계에서는 network를 완전히 끄면 공격 표면이 크게 줄어든다.
비밀값을 이미지와 환경 변수에 넣지 않는다
공용 runner image에 credential을 bake하면 모든 작업이 같은 장기 비밀값을 얻는다. 환경 변수도 child process와 diagnostic output에서 노출될 수 있다.
# 금지해야 할 예시
ENV CLOUD_ACCESS_KEY=long-lived-secret
COPY .ssh /root/.ssh
필요한 경우 AI 에이전트 권한을 최소화하는 방법에서 정리한 것처럼 task에 묶인 짧은 수명의 credential을 발급한다.
{
"subject": "agent-task:a1b2",
"audience": "artifact-reader",
"actions": ["artifact:read"],
"repository": "sample-api",
"expiresInSeconds": 300
}
전달 방식도 중요하다.
- 가능하면 broker가 요청을 대신 수행하고 raw secret은 주지 않는다.
- 파일로 mount하면 mode와 owner를 제한하고 작업 종료 시 제거한다.
- 로그 수집 전에 token pattern과 secret value를 redaction한다.
- core dump와 process inspection을 제한한다.
- 자격 증명은 sandbox ID와 audience에 binding한다.
- 승인 전에는 push, deploy와 production credential을 발급하지 않는다.
읽기 전용 credential도 source와 artifact를 대량 유출할 수 있다. read-only를 safe와 같은 뜻으로 사용해서는 안 된다.
캐시와 의존성 설치도 경계 안에 둔다
캐시는 성능을 크게 높이지만 작업 간 공유 통로가 될 수 있다. 이전 작업이 만든 실행 파일이나 package cache를 다음 작업이 신뢰하면 격리 경계가 흐려진다.
공용 writable cache
task A가 악성 compiler wrapper 기록
-> task B가 cache hit로 실행
-> 서로 다른 작업 사이 영향 전파
캐시를 다음처럼 분류할 수 있다.
| 캐시 | 권장 정책 |
|---|---|
| package download | 검증된 proxy의 read-only blob 사용 |
| build output | repository·revision·toolchain별 key, 작업 간 신뢰 금지 |
| compiler cache | tenant 분리, 무결성 검증, 실행 권한 주의 |
| model/tool cache | 사용자와 정책 버전까지 key에 포함 |
| credential cache | 공유 금지, task 종료 시 폐기 |
의존성 설치 script도 code execution이다. lockfile 고정, checksum 검증, package proxy와 lifecycle script 제한을 함께 사용한다.
# Node.js 프로젝트의 재구성 예시
npm ci --ignore-scripts
npm run build --if-present
npm test -- --runInBand
--ignore-scripts가 모든 프로젝트에 적용 가능한 것은 아니다. native module처럼 설치 script가 필요한 경우 별도 준비 단계에서 실행하고 network와 권한을 더 제한하거나 검증된 binary artifact를 제공한다.
결과는 Diff와 증거로 반출한다
샌드박스가 끝난 뒤 작업 디렉터리를 통째로 원본 위에 복사하면 .git, symlink, socket, 거대한 binary와 의도하지 않은 파일까지 반영될 수 있다. 반출에는 별도 policy가 필요하다.
type ArtifactManifest = {
baseRevision: string;
patchSha256: string;
changedFiles: Array<{
path: string;
operation: "add" | "modify" | "delete";
bytes: number;
}>;
checks: Array<{
name: string;
exitCode: number;
logSha256: string;
}>;
};
반출 단계에서는 다음을 검사한다.
- 경로를 canonicalize한 뒤 저장소 root 안인지 확인한다.
- symlink, device, socket와 특수 파일을 거부한다.
- 허용되지 않은 generated file과 binary를 분리한다.
- 전체 변경 파일 수와 byte 한도를 검사한다.
- secret scanner와 malware scan을 실행한다.
- patch가 선언한 base revision과 승인 대상 revision을 대조한다.
- 깨끗한 새 환경에 patch를 적용해 검사를 다시 실행한다.
마지막 단계가 중요하다. 에이전트가 테스트를 통과시킨 sandbox 자체가 조작되었을 가능성이 있기 때문이다. 산출물만 새 검증 환경에 적용하고 동일한 test를 다시 실행하면 작업 환경과 검증 환경의 결탁 가능성을 줄일 수 있다.
sequenceDiagram
participant S as Sandbox
participant P as Policy Gateway
participant V as Clean Verifier
participant H as Human/Repository
S->>P: patch + manifest + logs
P->>P: 경로·크기·secret 검사
P->>V: base revision + patch
V->>V: 깨끗한 환경에서 build/test
V-->>P: 검증 결과와 checksum
P-->>H: diff와 증거 제시
H->>P: 승인
P->>H: 현재 revision 대조 후 적용재구성한 Sandbox Runner 예제
다음 코드는 특정 서비스의 실제 구현이 아니라 책임 분리를 보여 주기 위한 TypeScript 예시다. 핵심은 모델이 container option이나 host path를 직접 만들지 않는다는 점이다.
type RunRequest = {
taskId: string;
repositoryId: string;
baseRevision: string;
commandProfile: "node-check" | "unit-test";
};
type SandboxSpec = {
imageDigest: string;
cpuCores: number;
memoryMb: number;
pids: number;
timeoutSeconds: number;
networkProfile: "none" | "package-proxy";
mounts: Array<{
sourceId: string;
target: "/workspace" | "/output";
readOnly: boolean;
}>;
};
허용된 profile을 서버가 정적으로 관리한다.
const commandProfiles = {
"node-check": {
argv: ["npm", "run", "check", "--if-present"],
network: "none",
timeoutSeconds: 300,
},
"unit-test": {
argv: ["npm", "test", "--", "--runInBand"],
network: "none",
timeoutSeconds: 600,
},
} as const;
function buildSpec(request: RunRequest): SandboxSpec {
const profile = commandProfiles[request.commandProfile];
return {
imageDigest: "sha256:example-pinned-runner-digest",
cpuCores: 2,
memoryMb: 4096,
pids: 128,
timeoutSeconds: profile.timeoutSeconds,
networkProfile: profile.network,
mounts: [
{ sourceId: `workspace:${request.taskId}`, target: "/workspace", readOnly: false },
{ sourceId: `output:${request.taskId}`, target: "/output", readOnly: false },
],
};
}
모델이 argv, image, mount source를 임의 문자열로 제출하게 두면 prompt injection이 곧 runtime configuration injection이 된다. 모델은 허용된 commandProfile만 제안하고, trusted orchestrator가 정책에서 실제 실행 사양을 조립한다.
async function runTask(request: RunRequest): Promise<ArtifactManifest> {
await policy.assertTaskAllowed(request);
await repository.assertRevisionExists(
request.repositoryId,
request.baseRevision,
);
const profile = commandProfiles[request.commandProfile];
const spec = buildSpec(request);
const sandbox = await runtime.create(spec);
try {
await sandbox.checkoutReadOnlySource({
repositoryId: request.repositoryId,
revision: request.baseRevision,
destination: "/workspace",
});
const result = await sandbox.exec({
argv: [...profile.argv],
cwd: "/workspace",
timeoutSeconds: profile.timeoutSeconds,
environment: {
CI: "true",
HOME: "/tmp/task-home",
},
});
await audit.recordCommandResult(request.taskId, {
exitCode: result.exitCode,
elapsedMs: result.elapsedMs,
stdoutSha256: result.stdoutSha256,
stderrSha256: result.stderrSha256,
});
return await artifactExporter.exportValidatedPatch({
sandboxId: sandbox.id,
baseRevision: request.baseRevision,
maxFiles: 100,
maxBytes: 5 * 1024 * 1024,
});
} finally {
await credentialBroker.revokeTask(request.taskId);
await runtime.destroy(sandbox.id);
}
}
이 예시는 방향을 보여 줄 뿐 완전한 sandbox 구현은 아니다. runtime.create 아래에서 namespace, cgroup, seccomp, LSM, network policy와 image verification을 강제해야 한다. finally의 정리도 실패할 수 있으므로 별도 janitor가 만료된 sandbox를 반복 탐지하고 제거해야 한다.
"npm " + userInput 같은 문자열을 shell에 넘기면 command injection이 생긴다. 가능한 경우 shell을 거치지 않고 검증된 executable과 인수 배열을 runtime API에 전달한다.
격리가 깨지는 흔한 경로
샌드박스를 도입하고도 아래 설정 하나 때문에 경계가 사라지는 경우가 많다.
Docker socket을 mount한다
# 위험한 예시
volumes:
- /var/run/docker.sock:/var/run/docker.sock
안쪽 프로세스가 runtime API로 host filesystem을 mount한 새 컨테이너를 만들 수 있다면 사실상 host 제어 권한에 가깝다. 빌드에 컨테이너가 필요하면 전용 원격 builder, rootless builder 또는 작업별 격리 worker를 사용한다.
원본을 writable bind mount한다
/srv/repository:/workspace:rw
이 방식은 빠르지만 sandbox의 실수가 즉시 원본 훼손으로 이어진다. immutable snapshot이나 작업 전용 volume을 만들고 patch만 반출한다.
Host credential을 그대로 전달한다
AWS_PROFILE, kubeconfig, SSH agent와 Git credential helper는 의도하지 않은 권한을 이어 준다. 환경 변수 allowlist를 만들고 기본 환경을 비운 뒤 필요한 값만 새로 구성한다.
const safeEnvironment = {
PATH: "/usr/local/bin:/usr/bin:/bin",
HOME: "/tmp/task-home",
LANG: "C.UTF-8",
CI: "true",
};
이미지 tag만 고정한다
runner:latest나 변경 가능한 version tag는 같은 작업이 다른 image에서 실행될 수 있다. digest를 pin하고 image signature와 SBOM, vulnerability policy를 검증한다.
로그를 무제한 보존한다
로그에는 source fragment, token, 개인정보가 포함될 수 있다. 수집 전 redaction, byte 한도, 접근 제어와 보존 기한이 필요하다. 전체 stdout 대신 checksum과 제한된 tail만 보존하는 선택도 가능하다.
샌드박스를 테스트하는 방법
정상적인 npm test가 성공하는지만 확인하면 격리를 검증한 것이 아니다. 의도적으로 경계를 넘으려는 negative test가 필요하다.
const isolationCases = [
{ name: "read host home", expect: "denied" },
{ name: "write original repository", expect: "denied" },
{ name: "connect cloud metadata", expect: "denied" },
{ name: "connect arbitrary internet host", expect: "denied" },
{ name: "open container runtime socket", expect: "not-found" },
{ name: "spawn beyond PID limit", expect: "limited" },
{ name: "allocate beyond memory limit", expect: "terminated" },
{ name: "write beyond disk quota", expect: "limited" },
{ name: "run beyond timeout", expect: "terminated" },
{ name: "export symlink outside workspace", expect: "rejected" },
] as const;
테스트는 적어도 네 층으로 나눈다.
- Policy unit test: 요청이 올바른 profile과 limit으로 변환되는지 확인한다.
- Runtime integration test: 실제 namespace, mount와 network 차단을 시도한다.
- Artifact test: path traversal, symlink, 큰 파일과 secret 반출을 거부한다.
- Failure test: runtime crash, cleanup 실패, timeout과 중복 요청을 주입한다.
Given task a1b2가 base revision abc123으로 생성됨
When sandbox 안에서 /host/home/user/.ssh 읽기를 시도함
Then read는 실패함
And policy_violation 이벤트가 task a1b2에 기록됨
And task 종료 뒤 sandbox와 task credential이 남지 않음
격리 제품이나 kernel version을 올릴 때도 동일한 suite를 실행한다. 구성 파일의 의도와 실제 runtime의 동작이 달라질 수 있기 때문이다.
운영에서 관찰해야 할 지표
샌드박스가 너무 자주 실패하면 사용자는 권한을 넓혀 우회하려 한다. 보안 위반뿐 아니라 성능과 오탐도 함께 관찰해야 한다.
| 지표 | 의미 |
|---|---|
| provisioning latency | 작업 시작 전 대기 시간 |
| sandbox active count | 현재 격리 작업 수와 용량 |
| timeout rate | 명령·작업 시간 한도 적합성 |
| OOM / PID / disk limit rate | 자원 profile 적합성 |
| network deny count | 불필요 접근 또는 누락된 allowlist |
| artifact rejection rate | path·size·secret 정책 위반 |
| cleanup lag | 종료 후 잔여 자원 노출 시간 |
| orphan sandbox count | 상태 머신과 janitor 이상 |
| cache hit rate | 격리를 해치지 않는 최적화 효과 |
| revalidation failure rate | sandbox 결과와 clean verifier 차이 |
감사 로그에는 raw source나 secret 대신 연결 가능한 식별자와 checksum을 남긴다.
{
"event": "sandbox.command.finished",
"taskId": "a1b2",
"sandboxId": "sbx-7f20",
"imageDigest": "sha256:example",
"policyVersion": "sandbox-policy-12",
"commandProfile": "unit-test",
"exitCode": 0,
"elapsedMs": 18420,
"networkDenied": 0,
"recordedAt": "2026-05-01T11:03:12Z"
}
taskId를 proposal, 승인, credential 발급, command, artifact와 원본 반영 이벤트에 공통으로 넣으면 한 작업의 전체 흐름을 추적할 수 있다. 추적 방식은 분산 추적에서 Trace ID와 Span ID 역할의 관점과도 닿아 있다.
마무리
에이전트 작업을 안전하게 실행하려면 작업 폴더 하나를 따로 만드는 데서 멈추면 안 된다. Git worktree는 편집 충돌을 줄이지만 그 자체가 보안 경계는 아니다. 파일 시스템, 프로세스, network, credential, resource와 artifact 반출을 각각 제한해야 한다.
원본 저장소는 읽기 전용 입력으로 두고, 에이전트가 만든 결과는 제한된 diff와 검증 증거로만 원본에 반영한다.
실무에서는 다음 순서로 적용할 수 있다.
- 작업마다 고유한 ID, base revision과 만료 시간을 만든다.
- 신뢰 수준에 따라 clone, worktree, container 또는 VM 경계를 선택한다.
- non-root, capability drop, read-only root와 syscall policy를 적용한다.
- CPU, memory, PID, disk, output과 시간 한도를 둔다.
- network는 default deny하고 필요한 proxy만 단계별로 연다.
- host credential 대신 task 전용 단기 credential을 사용한다.
- cache와 package installation을 작업 간 공격 통로로 보지 않게 분리한다.
- path, symlink, 크기와 secret 검사를 통과한 patch만 반출한다.
- 깨끗한 검증 환경에서 patch를 다시 적용해 test한다.
- 성공·실패·취소·만료 모두 credential 폐기와 sandbox 정리로 끝낸다.
- negative test로 실제 경계 밖 접근이 차단되는지 확인한다.
- 위반뿐 아니라 timeout, cleanup lag와 오탐을 함께 관찰한다.
샌드박스의 가치는 에이전트가 늘 올바르게 행동하게 만드는 데 있지 않다. 잘못된 명령, 악성 의존성, 오염된 입력이 들어와도 이번 작업을 위해 만든 짧은 수명의 경계 안에서 실패하게 만드는 데 있다.
참고 자료
- Git - git-worktree Documentation
- Linux Kernel Documentation - Control Group v2
- Docker Docs - Resource constraints
- Docker Docs - Seccomp security profiles
- Docker Docs - Rootless mode
- Open Container Initiative - Runtime Specification