9장. 격리 작업 공간
다음은 여러 현장에서 반복된 패턴을 합친 합성 사례로, 등장하는 숫자는 설명을 위한 예시 수치다.
두 에이전트가 같은 저장소에서 동시에 일했다. 하나는 의존성을 올렸고 다른 하나는 테스트를 실행했다. 두 번째 실행은 자신이 바꾸지 않은 잠금 파일과 캐시를 읽었고, 간헐적으로 성공했다. 첫 번째 작업이 실패해 되돌렸지만 생성 파일 일부는 남았다. 어느 결과가 어떤 입력에서 나왔는지 아무도 확신할 수 없었다.
격리는 컨테이너라는 제품을 쓰는 일이 아니다. 한 실행의 입력·변경·권한·부작용을 다른 실행과 분리하고, 결과의 출처를 설명할 수 있게 하는 속성이다.
이번 장의 약속
- 실행별 격리의 대상과 위협을 정의한다.
- 복사본, Git worktree, 컨테이너, 원격 샌드박스를 상황에 맞게 선택한다.
- 경로 탈출, 임의 명령, 비밀 노출을 실행기에서 막는다.
- 작업 공간의 생성·임대·보존·정리를 수명 주기로 관리한다.
무엇을 무엇으로부터 격리하는가
격리 설계 전에 자산과 위협을 적는다.
| 자산 | 위협 | 필요한 경계 |
|---|---|---|
| 기준 소스 | 미완성 변경의 오염 | 실행별 쓰기 공간 |
| 다른 작업 결과 | 같은 파일·캐시 충돌 | 작업 공간·캐시 네임스페이스 |
| 호스트와 사용자 파일 | 경로 탈출·임의 명령 | 파일 루트·프로세스 샌드박스 |
| 비밀 | 로그·모델 입력·외부 전송 | 주입 최소화·마스킹·네트워크 정책 |
| 외부 시스템 | 잘못된 메시지·배포·삭제 | 자격 증명·승인·테스트 대역 |
| 실행 증거 | 정리 중 손실·변조 | 읽기 전용 산출물 보존 |
Git 브랜치는 변경 이력을 분리하지만 같은 작업 디렉터리의 생성 파일과 프로세스를 격리하지 않는다. 컨테이너는 프로세스와 파일시스템 경계를 강화하지만 잘못 마운트한 호스트 경로나 강한 자격 증명은 여전히 위험하다. 격리 수단의 이름보다 위협별 방어를 확인한다.
네 가지 작업 공간 선택지
디렉터리 복사
가장 이해하기 쉽고 Git이 없어도 동작한다. 작은 실습과 결정론적 테스트에 적합하다. 대형 저장소에서는 느리고 저장 공간을 많이 쓰며, 파일 메타데이터와 무시 규칙을 세심하게 다뤄야 한다.
Git worktree
같은 저장소 객체를 공유하면서 다른 커밋과 작업 디렉터리를 만든다. 로컬 개발과 여러 변경의 병렬 작업에 효율적이다. 같은 브랜치를 여러 worktree에서 체크아웃할 수 없는 규칙, 공용 Git 메타데이터, 하위 모듈·LFS·훅을 이해해야 한다.
컨테이너
의존성과 프로세스를 이미지로 고정하고 파일·사용자·네트워크를 제한할 수 있다. CI와 팀 재현성이 좋다. 이미지 빌드, 캐시, 커널 공유, 마운트·권한 설정이라는 운영 비용이 있다.
원격 일회성 샌드박스
호스트와 물리적으로 더 분리하고 탄력적으로 병렬 실행할 수 있다. 시작 지연, 데이터 이동, 비용, 지역·규제, 서비스 의존성을 고려한다.
| 상황 | 기본 선택 | 강화 시점 |
|---|---|---|
| 이 책의 무API 실습 | 디렉터리 복사 | 경로/명령 정책 테스트 |
| 신뢰된 내부 저장소의 로컬 병렬 작업 | Git worktree | 민감 작업은 컨테이너 |
| CI의 생성 코드 실행 | 비루트 컨테이너 | 네트워크·시스템콜 제한 |
| 불신 코드·다수 테넌트 | 원격 일회성 샌드박스 | 계정·네트워크까지 분리 |
깨끗한 기준선에서 시작한다
작업 공간 생성기는 다음 순서를 지킨다.
- 작업 계약과 기준 커밋을 고정한다.
- 기준 저장소에 추적되지 않은 사용자 변경이 있는지 확인한다.
runId에 전용 디렉터리와 브랜치/복사본을 만든다.- 허용된 의존성 캐시만 읽기 전용 또는 전용 네임스페이스로 연결한다.
- 정책과 도구 버전을 입력 산출물에 기록한다.
- 시작 상태의 파일 목록 또는 Git 상태가 깨끗한지 확인한다.
사용자의 더러운 작업 트리를 자동으로 정리하거나 초기화하지 않는다. 새 기준 커밋을 요구하거나 추적 파일만 복사해 별도 공간을 만든다. 하니스 편의를 위해 사람의 미커밋 작업을 지우는 것은 허용되지 않는다.
경로는 문자열 접두사가 아니다
허용 루트가 /work/run/src라고 해서 다음 검사로 충분하지 않다.
위험한 반례(복사 금지) — 문자열 접두사만 검사하면 형제 경로·상위 경로·심볼릭 링크 탈출을 허용할 수 있다.
if (requestedPath.startsWith(allowedRoot)) { /* allow */ }
/work/run/src-evil, ../, 심볼릭 링크를 이용한 탈출을 놓칠 수 있다. 경로를 정규화하고 실제 경로를 해석한 뒤 상대 관계를 확인한다.
설계 예(실행용 아님) — 원본: 이 장의 운영용 경로 방어 설명; 명령: 없음. 실제 로컬 구현: 03-labs/src/path-safety.mjs.
import path from "node:path";
import fs from "node:fs/promises";
export async function assertInside(root, requested) {
const realRoot = await fs.realpath(root);
const candidate = path.resolve(realRoot, requested);
const parent = await fs.realpath(path.dirname(candidate));
const realCandidate = path.join(parent, path.basename(candidate));
const relative = path.relative(realRoot, realCandidate);
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
return realCandidate;
}
throw new Error(`[SEC_PATH_OUTSIDE] 요청 경로가 허용 루트 밖입니다: ${requested}`);
}
새 파일은 자신이 아직 존재하지 않아 realpath할 수 있으므로 실제 부모를 확인한다. 쓰기 직전에 심볼릭 링크가 바뀌는 경쟁 조건까지 위협에 포함하면 파일 디스크립터 기반 API나 더 강한 샌드박스가 필요하다. 작은 경로 검사 하나가 운영체제 격리를 대체한다고 생각하지 않는다.
셸 문자열 대신 프로그램과 인자를 분리한다
작업 ID나 파일명을 명령 문자열에 합치면 입력이 셸 문법으로 해석될 수 있다.
설계 예(실행용 아님) — 원본: 이 장의 프로세스 호출 설명; 명령: 없음.
// 위험: task.id가 셸 구문으로 해석될 수 있다.
exec(`npm test -- ${task.id}`);
// 기본: 셸 없이 실행 파일과 인자를 분리한다.
spawn("npm", ["test", "--", task.id], {
cwd: workspace,
shell: false,
stdio: "pipe"
});
그다음 실행 가능한 프로그램과 하위 명령을 허용 목록으로 제한한다. shell: false만으로 node -e <임의 코드>나 위험한 프로그램 실행이 안전해지는 것은 아니다.
최소 권한의 세 방향
파일
기본은 작업 공간 읽기, allowedPaths 쓰기다. 정책, CI, 비밀, 상위 디렉터리, 다른 실행 산출물은 읽기/쓰기를 별도로 제한한다.
네트워크
테스트와 빌드가 네트워크 없이 동작하도록 만드는 것이 가장 강한 기본값이다. 필요하면 목적지·메서드·용도를 좁히고 외부 전송을 기록한다. 패키지 설치는 잠금 파일과 승인된 레지스트리를 사용한다.
자격 증명
모든 작업에 공용 장기 토큰을 넣지 않는다. 작업에 필요한 최소 범위, 짧은 수명, 특정 환경을 가진 자격 증명을 행동 직전에 제공한다. 로그와 모델 문맥에는 원문을 넣지 않는다.
작업 공간 임대와 수명 주기
작업 공간은 다음 상태를 가진다.
CREATING → READY → LEASED → SEALED → ARCHIVED → DELETED
│ │
└─> QUARANTINED
그림 9-1. 격리는 디렉터리 생성으로 끝나지 않는다. 임대 소유권과 봉인, 증거 보존, 격리, 삭제까지 관리한다. 교육용 예제는 디렉터리 스냅샷과 실행별 폴더를 구현하며 이 전체 운영 상태 기계를 구현했다고 주장하지 않는다.
LEASED: 하나의 실행만 쓸 수 있다. 소유 실행과 만료 시각을 기록한다.SEALED: 실행이 끝나 더 이상 변경할 수 없다. diff와 게이트를 계산한다.QUARANTINED: 보안 위반·비정상 종료로 조사 전 자동 재사용하지 않는다.ARCHIVED: 필요한 산출물만 별도 보존했다.DELETED: 임시 작업 공간을 제거했다.
정리기는 완료 상태만 믿지 않는다. 프로세스 종료, 열린 임대, 산출물 복사와 해시 확인 뒤 삭제한다. 실패 작업은 디버깅에 필요하지만 무기한 보존하면 비용과 민감 데이터 위험이 커진다. 위험 등급별 보존 기간을 둔다.
실습 1: 같은 기준에서 두 공간 만들기
예제 공장의 캡스톤을 실행합니다. 첫 배치의 domain-model과 reader-docs는 같은 기준 릴리스에서 서로 다른 작업 공간을 사용합니다.
npm run demo
.factory/runs/order-capstone/workspaces/ 아래에서 두 작업의 .workspace.json을 비교합니다. 기준 해시는 같고 작업 ID와 디렉터리는 달라야 합니다. 실습 결과를 보존하려면 원본 파일을 바꾸지 말고 복사본에서 첫 공간의 파일을 수정한 뒤 두 번째 공간의 해시가 변하지 않는지 확인합니다.
확인
같은 기준 입력
서로 다른 쓰기 경로
서로 다른 이벤트·산출물 경로
한 공간의 변경이 다른 공간에 보이지 않음
실습 2: 경로 탈출 공격을 실패시킨다
npm run test:security -- --case path-traversal
검사는 적어도 다음 입력을 다룬다.
../outside.txt
../../factory/policy.json
src/../../outside.txt
workspace-link/secret.txt # 외부를 가리키는 심볼릭 링크
기대 결과는 입력 종류에 따라 [SEC_PATH_TRAVERSAL], [SEC_PATH_ABSOLUTE], [SEC_PATH_OUTSIDE], [SEC_SYMLINK_PATH] 가운데 하나이며 외부 파일의 생성·변경이 없어야 한다. 이 이름은 03-labs/src/path-safety.mjs와 보안 회귀 검사의 실제 오류 코드다. 오류 메시지에 호스트의 민감한 전체 경로를 불필요하게 노출하지 않는다.
실습 3: 충돌을 격리하고 통합에서 드러낸다
같은 기준 파일을 다르게 고치는 작업 두 개를 별도 공간에서 실행한다. 두 실행 모두 로컬 게이트는 통과할 수 있다. 격리는 충돌을 없애지 않고 각 작업을 오염 없이 완료한 뒤 통합 단계에서 명시적으로 드러내게 한다.
npm run demo:conflict
실제 핵심 출력은 다음과 같습니다.
[배치 1] alpha-change, beta-change
✓ alpha-change: 리뷰·구문·구조 검사 통과 후 통합
✗ beta-change: 통합 충돌, 재시도 예약
[배치 2] beta-change
✓ beta-change: 리뷰·구문·구조 검사 통과 후 통합
GREEN ✓ — 병렬 변경 충돌과 결정론적 재시도
두 작업은 src/shared.mjs를 서로 다른 내용으로 바꿉니다. alpha-change가 먼저 통합되면 beta-change의 기준 해시가 달라져 통합 충돌이 발생하고, 새 시도 작업 공간에서 최신 릴리스를 기준으로 재실행합니다. 이 fixture는 충돌과 재시도를 재현하지만 일반적인 3-way merge나 의미 충돌 해결기를 제공하지 않습니다.
캐시는 속도와 재현성 사이의 계약이다
의존성 캐시와 빌드 캐시는 유용하지만 숨은 입력이 될 수 있다.
- 캐시 키에 운영체제, 런타임, 잠금 파일, 도구 버전을 포함한다.
- 작업이 캐시 내용을 수정하지 못하도록 읽기 전용 또는 실행별 쓰기 층을 둔다.
- 캐시 히트 여부를 이벤트로 남긴다.
- 중요한 회귀 검사는 주기적으로 빈 캐시에서도 실행한다.
- 캐시된 생성물이 제품 산출물로 통합될 때 출처를 확인한다.
왜 실패하는가
브랜치만 만들면 격리됐다고 본다
같은 작업 디렉터리, 캐시, 실행 프로세스, 환경 변수를 공유하면 부작용이 섞인다. 위협별 경계를 확인한다.
호스트 홈 디렉터리를 통째로 마운트한다
편리하지만 SSH 키, 클라우드 자격 증명, 다른 저장소가 노출된다. 필요한 파일만 읽기 전용으로 제공하고 작업용 자격 증명을 별도로 쓴다.
실패 공간을 즉시 삭제한다
원인 증거가 사라진다. 산출물을 봉인하고 민감 정보 검사를 거친 뒤 정책 기간 동안 보존한다.
정리 실패를 무시한다
고아 프로세스와 작업 공간이 자원 고갈과 데이터 잔존을 만든다. 임대 만료, 고아 탐지, 사용량 한도를 관측한다.
운영 판단: 어느 수준까지 격리할까
다음 위험이 하나라도 높으면 파일 복사나 worktree를 넘어 프로세스·네트워크 격리를 강화한다.
- 신뢰할 수 없는 생성 코드를 실행한다.
- 외부 입력이 코드나 명령에 영향을 준다.
- 비밀·개인정보·운영 시스템에 접근한다.
- 여러 사용자나 팀의 작업을 같은 호스트에서 실행한다.
- 공급망 설치 스크립트를 실행한다.
- 실패 영향이 저장소 한 개를 넘는다.
격리 수준을 높여도 승인, 최소 권한, 결과 검증은 남는다.
연습문제
- Git 브랜치가 분리하지만 컨테이너가 추가로 분리하는 자원을 세 개 적어라.
startsWith경로 검사의 우회 입력을 만들고 안전한 판정 절차를 설명하라.- 팀의 작업을 로컬 worktree, 컨테이너, 원격 샌드박스로 나누는 위험 기준을 작성하라.
- 실패 작업 공간의 증거 보존과 개인정보 삭제 요구가 충돌할 때 보존 정책을 설계하라.
체크포인트
- 실행마다 고정된 기준, 전용 쓰기 공간, 전용 산출물이 있다.
- 경로 탈출과 셸 명령 주입을 자동 테스트로 거부한다.
- 비밀·네트워크·파일 권한이 작업 위험에 맞게 제한된다.
- 작업 공간 임대, 봉인, 격리, 보존, 정리가 관측된다.
다음 장에서는 격리된 작업을 그래프와 실제 용량에 맞춰 스케줄하고, 병렬성이 처리량을 해치기 전에 역압을 거는 오케스트레이터를 만든다.