ouroboros · 가드레일: 샌드박스 클래스 · 권한 번역 · 플러그인 방화벽
ouroboros · 가드레일: 샌드박스 클래스 · 권한 번역 · 플러그인 방화벽
한 줄 요약
AI 에이전트가 내 컴퓨터를 얼마나 만질 수 있는지를 “결정 한 곳 + 번역 여러 곳 + 외부 출입 단일 정문”으로 통제하는 3중 안전장치다. 왜 배우나: AI에게 권한을 주되 폭주를 막는 설계는 모든 에이전트 도구의 생명선이고, ouroboros의 방식이 그 교과서적 모범이기 때문이다.
그림
flowchart TD
A["세션 역할 확정<br/>인터뷰·코디·구현 등"] --> B["derive_sandbox_class<br/>엔진이 단 한 번 결정"]
B --> C{"어느 백엔드인가?"}
C -->|Claude| D[permission_mode 로 번역]
C -->|Codex| E["--sandbox / --full-auto 로 번역"]
C -->|Copilot| F[--allow-all-tools 로 번역]
D & E & F --> G["build_child_env<br/>부모 마커 제거 + 깊이 +1"]
G --> H[에이전트 CLI 실행]
H --> I{"외부 플러그인 호출?"}
I -->|예| J[invoke_plugin 단일 정문]
J --> K["신뢰검사 → 1회 확인 → 해시 재계산 → 감사기록"]
I -->|아니오| L[정상 작업 계속]
쉽게 풀기
이 가드레일은 한 문장으로 “누가 무엇을 정하고, 누가 그걸 옮겨 적기만 하는가”를 칼같이 나눈 설계다. 회사 건물의 보안 체계에 비유하면 이해가 빠르다.
-
출입 등급을 정하는 보안실 (엔진) 세션이 시작될 때 그 세션의 “역할”이 정해진다. 인터뷰·평가 역할이면 “구경만 가능”, 코디네이터면 “내 작업폴더만 손댐”, 구현 역할이면 “무제한”. 이 등급 판정은 건물 전체에서 딱 한 군데, 보안실에서만 내려진다. 코드로는
derive_sandbox_class()함수 하나다. 등급의 이름표는 백엔드(Claude/Codex/Copilot)와 무관한 중립 단어 3개뿐이다:READ_ONLY,WORKSPACE_WRITE,UNRESTRICTED. -
등급을 자기 카드리더 형식으로 바꾸는 층별 게이트 (어댑터) 각 층의 출입문(=각 AI 백엔드)은 카드리더 방식이 제각각이다. 보안실이 정한 “구경만 가능”이라는 등급을, Claude 게이트는
permission_mode=default로, Codex 게이트는--sandbox read-only로, Copilot 게이트는 빈 도구목록으로 옮겨 적기만 한다. 게이트는 절대 “음, 이 사람은 사실 더 줘도 되겠네” 하고 스스로 다시 판단하지 않는다. 만약 새 등급이 생겼는데 게이트의 변환표에 없으면, 조용히 통과시키는 게 아니라 시끄럽게 오류를 내며 멈춘다(기본값이 “관대”가 아니라 “거절”). -
부모 흔적을 지우고 깊이를 세는 출입증 발급 (자식 프로세스 격리) 에이전트가 또 다른 에이전트를 불러내는(spawn) 일이 있는데, 이때 부모의 런타임 표식을 환경변수에서 지운다. 자식이 부모인 척 다시 들어오는 걸 막기 위해서다. 또 호출이 한 단계 깊어질 때마다 깊이 카운터를 +1 하고, 기본 5단계를 넘으면 예외를 던진다. 끝없이 자기를 복제하는 “포크 폭탄”을 막는 천장이다.
-
외부 업체는 무조건 단일 정문 경비를 거침 (플러그인 방화벽) 외부 플러그인을 실행할 때는
invoke_plugin이라는 단 하나의 문(초크포인트)만 통과시킨다. 이 문에서 신뢰 검사 → 사용자 1회 확인 → 무결성 해시 재계산 → 감사 로그 기록을 강제한다. 핵심 규칙: 신뢰 검사에서 막히면 “실행됐다(plugin.invoked)“는 기록은 절대 남지 않는다. 차단은 차단으로만 기록된다.
핵심 정리
| 안전장치 | 담당 | 한마디로 |
|---|---|---|
| 샌드박스 등급 결정 | 엔진(보안실) | 역할→등급, 한 곳에서만 |
| 권한 번역 | 어댑터(층별 게이트) | 등급→CLI 깃발, 옮겨 적기만 |
| 자식 격리 | 런타임 | 부모 마커 제거 + 깊이 천장 |
| 플러그인 방화벽 | 단일 초크포인트 | 신뢰·확인·해시·감사 강제 |
[!note] 백엔드 중립 어휘 — SandboxClass 3종 (
src/ouroboros/sandbox.py)
READ_ONLY(read_only): 호스트 상태 조회만, 변경 불가 — 인터뷰·평가 역할WORKSPACE_WRITE(workspace_write): 작업폴더 파일만 변경, 외부 호스트 차단 — 코디네이터 역할UNRESTRICTED(unrestricted): 승인·샌드박스 게이트 우회 — 모든 어댑터가 warning 의무
[!note] 엔진 정책 매핑 (
orchestrator/policy.py)
PolicyContext.session_role(필수): 유일하게 결정을 구동하는 필드PolicyContext.execution_phase(필수): 감사 이벤트에 단계 귀속용PolicyContext.runtime_backend(선택): 감사·리플레이용, 현재 결정엔 미사용(전방 호환 훅)_ROLE_SANDBOX_CLASS: INTERVIEW/EVALUATION→READ_ONLY, COORDINATOR→WORKSPACE_WRITE, IMPLEMENTATION→UNRESTRICTED
[!note] 백엔드 번역 표 (평탄 룩업, 정책 판단 0)
SandboxClass Claude permission_modeCodex CLI Copilot CLI READ_ONLY default--sandbox read-only--available-tools=WORKSPACE_WRITE acceptEdits--full-auto--allow-all-toolsUNRESTRICTED bypassPermissions--dangerously-bypass-approvals-and-sandbox--allow-all
[!note] 자식 프로세스 env 격리 (
runtime/child_env.py)
OUROBOROS_DEPTH_ENV_KEY=_OUROBOROS_DEPTH: 중첩 spawn마다 +1, 초과 시 예외DEFAULT_MAX_OUROBOROS_DEPTH=5: 재귀 천장(fork bomb 방지)DEFAULT_OUROBOROS_STRIP_KEYS=("OUROBOROS_AGENT_RUNTIME","OUROBOROS_LLM_BACKEND"): 모든 백엔드가 제거(자식의 부모 런타임 재진입 차단)
[!note] 플러그인 방화벽 감사 이벤트 (
plugin/firewall.py, schema 0.1 / hooks 0.3)
event_type 발생 시점 result.status plugin.failed (blocked)신뢰 미충족·disable·digest 변경·확인 거부 blocked / trust_subject_changed plugin.invoked엔트리포인트 subprocess 직전 success plugin.permission_usedrequired:true권한마다 1개씩success plugin.completed종료코드 0 success plugin.failed (failed)비정상 종료·타임아웃·실행불가 failed 신뢰 검사 차단 시
plugin.invoked는 절대 발생하지 않는다(잠긴 Q1 계약).
[!note] Lockfile 신뢰 주체 (
plugin/lockfile.py)
name/version(필수): 플러그인 식별source_type(선택, legacy 빈문자):local_path/plugin_home/first_partysource_identity(선택): 정규화된 repo URL 또는 절대 경로artifact_digest(선택): 설치 산출물 canonical tree hash,sha256:<hex>신뢰 주체 =
(version, source.type, source_identity, artifact_digest)튜플. 한 컬럼이라도 바뀌면 권한 부여가 무효화된다(코드 치환 방어).
모델은 이 어휘를 직접 보지 않는다
기억할 큰 그림 하나: 이 가드레일은 모델이 읽는 **프롬프트가 아니라, 모델을 감싸는 실행 봉투(envelope)**다. 모델 호출 직전에 호출 환경(권한 모드·CLI 깃발·env)을 정해 에이전트 프로세스를 띄우고, 모델이 도구를 쓰려 할 때 백엔드 런타임이 그 봉투 안에서만 허용/차단한다.
- 언제: 세션 시작(역할 확정) 시 1회 샌드박스 결정 → 백엔드 어댑터 기동 시 번역 → 자식 CLI spawn 시 env 격리 → 플러그인 호출마다 방화벽 통과.
- 어떻게: 결정은
derive_sandbox_class(context)로 단일 enum 산출. 번역은 각*_permissions.py가 enum→CLI 형식. 주입 위치는 프롬프트 본문이 아니라 SDKpermission_mode인자 / CLI argv 깃발 / 자식 프로세스 환경변수. - 핵심 교훈: “정책=무엇을 허용하나”는 엔진이, “표현=각 모델 CLI에 어떻게 말하나”는 어댑터가 담당하는 엔진-어댑터 경계. 모델의 도구 표면(어떤 도구가 보이고 실행 가능한지,
evaluate_capability_policy)과 부작용 한도(샌드박스)를 런타임이 이 봉투로 결정한다.
실제 예시
엔진은 역할을 받아 중립 등급 enum 하나만 내놓는다. “결정은 여기, 단 한 곳”이라는 원칙이 코드로 그대로 드러난다.
# src/ouroboros/orchestrator/policy.py
# 세션 역할 → 백엔드 중립 샌드박스 클래스. 엔진 소유, 단 한 곳의 결정.
_ROLE_SANDBOX_CLASS: dict[PolicySessionRole, SandboxClass] = {
PolicySessionRole.INTERVIEW: SandboxClass.READ_ONLY,
PolicySessionRole.EVALUATION: SandboxClass.READ_ONLY,
PolicySessionRole.COORDINATOR: SandboxClass.WORKSPACE_WRITE,
PolicySessionRole.IMPLEMENTATION: SandboxClass.UNRESTRICTED,
}
def derive_sandbox_class(context: PolicyContext) -> SandboxClass:
"""엔진의 권위적 답: '이 세션은 어느 샌드박스 레벨인가?'
어댑터는 이 enum을 룩업 표로 번역만 할 뿐, 자유형 권한 문자열로
결정을 재계산해서는 안 된다."""
return _ROLE_SANDBOX_CLASS[context.session_role]
어댑터는 그 enum을 자기 CLI 형식으로 옮겨 적기만 한다. 표에 없으면 조용히 봐주지 않고 시끄럽게 실패하고, 무제한 등급이면 반드시 경고를 남긴다.
# src/ouroboros/claude_permissions.py
# 엔진 SandboxClass → Claude SDK permission_mode (번역 전용, 판단 없음).
_SANDBOX_TO_CLAUDE_MODE: dict[SandboxClass, ClaudePermissionMode] = {
SandboxClass.READ_ONLY: "default",
SandboxClass.WORKSPACE_WRITE: "acceptEdits",
SandboxClass.UNRESTRICTED: "bypassPermissions",
}
def claude_permission_mode_for_sandbox(sandbox: SandboxClass) -> ClaudePermissionMode:
mode = _SANDBOX_TO_CLAUDE_MODE.get(sandbox)
if mode is None: # enum이 늘었는데 표를 안 고치면 조용히 관대해지는 대신 시끄럽게 실패
raise KeyError(f"No Claude SDK permission_mode registered for {sandbox!r}")
if sandbox is SandboxClass.UNRESTRICTED:
log.warning("permissions.bypass_activated", sandbox=sandbox.value)
return mode
직접 만들 때는 같은 골격을 그대로 베끼면 된다 — 중립 어휘 → 단일 결정 → 평탄 번역.
# my_guardrails.py — 결정/번역 분리 최소 구현
from enum import StrEnum
class SandboxClass(StrEnum): # ① 백엔드 중립 어휘 (3개 고정)
READ_ONLY = "read_only"
WORKSPACE_WRITE = "workspace_write"
UNRESTRICTED = "unrestricted"
_ROLE_TO_SANDBOX = { # ② 엔진: 단 한 곳에서 결정
"interview": SandboxClass.READ_ONLY,
"coordinator": SandboxClass.WORKSPACE_WRITE,
"implementation": SandboxClass.UNRESTRICTED,
}
def derive(role: str) -> SandboxClass:
return _ROLE_TO_SANDBOX[role] # 자유형 문자열로 재판단 금지
_TO_MYBACKEND = { # ③ 어댑터: 평탄 룩업 번역만
SandboxClass.READ_ONLY: ["--readonly"],
SandboxClass.WORKSPACE_WRITE: ["--write-cwd"],
SandboxClass.UNRESTRICTED: ["--yolo"], # 여기서 반드시 warning 로그
}
def translate(sb: SandboxClass) -> list[str]:
if sb not in _TO_MYBACKEND: # enum 성장 시 조용한 관대함 대신 시끄러운 실패
raise KeyError(sb)
return _TO_MYBACKEND[sb]
직접 구현 체크리스트:
- 샌드박스 어휘는 상향 의존 0인 최상위 모듈에 둔다(엔진·어댑터 양쪽이 순환 없이 import).
- 결정 함수는 하나, 역할→클래스 매핑 dict는 한 곳.
- 각 백엔드 번역은 평탄 dict 룩업, 미등록 시
KeyError로 즉시 실패(기본 관대 금지). - UNRESTRICTED 실현 시 모든 어댑터가 warning 발화.
- 자식 spawn: 부모 런타임 마커 strip +
_DEPTH카운터로 재귀 천장(기본 5) 강제. - 외부 플러그인: 단일
invoke_plugin초크포인트. 신뢰검사 차단 시 invoked 미발화, 매 호출 digest 재계산, argv 비밀값[redacted], stdout/stderr는 sha256만 원장에.
요약 & 셀프체크
3줄 요약:
- 권한 등급은 엔진이 역할을 보고 단 한 곳(
derive_sandbox_class)에서 중립 단어 3개 중 하나로 결정한다. - 각 백엔드 어댑터는 그 등급을 자기 CLI 깃발로 옮겨 적기만 하고, 미등록 등급은 조용히 봐주는 대신 시끄럽게 실패한다.
- 외부 플러그인은 단일 정문
invoke_plugin에서 신뢰·확인·해시·감사를 강제로 거치며, 차단되면 “실행됨” 기록이 남지 않는다.
스스로 답해보기:
- 인터뷰 역할 세션이 실수로 호스트 파일을 지우려 하면 어느 단계에서, 어떤 등급 때문에 막히나?
- 어댑터 변환표에 새 등급이 빠져 있을 때 “조용히 관대해지는” 대신 “시끄럽게 실패”하게 만든 이유는?
- 플러그인이 신뢰 검사에서 차단됐는데 감사 로그에
plugin.invoked가 보인다면, 무엇이 잘못된 것인가?
근거 파일
/home/seunghyeong/harness-work/ouroboros/src/ouroboros/sandbox.py/home/seunghyeong/harness-work/ouroboros/src/ouroboros/orchestrator/policy.py/home/seunghyeong/harness-work/ouroboros/src/ouroboros/claude_permissions.py/home/seunghyeong/harness-work/ouroboros/src/ouroboros/codex_permissions.py/home/seunghyeong/harness-work/ouroboros/src/ouroboros/copilot_permissions.py/home/seunghyeong/harness-work/ouroboros/src/ouroboros/codex/cli_policy.py/home/seunghyeong/harness-work/ouroboros/src/ouroboros/runtime/child_env.py/home/seunghyeong/harness-work/ouroboros/src/ouroboros/plugin/firewall.py/home/seunghyeong/harness-work/ouroboros/src/ouroboros/plugin/lockfile.py