# Claw Code 깊이 읽기 #5 — 도구 시스템과 권한 모델

조현상·2026년 4월 2일

ClaudeCode

목록 보기
5/17
post-thumbnail

AI 에이전트가 "행동"한다는 것은, 결국 도구를 실행한다는 것이다. 그 실행의 전체 경로를 추적한다.


들어가며: 도구가 에이전트를 만든다

LLM은 텍스트를 생성한다. 그 자체로는 유용하지만, 파일을 수정하거나 코드를 실행할 수는 없다. LLM이 "에이전트"가 되려면 현실 세계와 상호작용하는 도구(tool)가 필요하다.

Claw Code의 도구 시스템은 세 가지 질문에 답해야 한다:
1. 어떤 도구가 있는가? — ToolSpec과 mvp_tool_specs()
2. 이 도구를 실행해도 되는가? — PermissionPolicy와 3단계 권한
3. 어떻게 안전하게 실행하는가? — 샌드박스와 MCP 격리

이번 편에서는 이 세 질문을 따라가며, 도구 정의부터 실행, 권한 검증, 외부 도구 통합까지를 코드 레벨에서 분석한다.


1. ToolSpec: 도구의 자기 서술서

구조체 정의

pub struct ToolSpec {
    pub name: &'static str,
    pub description: &'static str,
    pub input_schema: Value,          // JSON Schema
    pub required_permission: PermissionMode,
}

네 개의 필드가 각각 다른 수신자를 위한 정보를 담고 있다:

필드수신자용도
name런타임 디스패처어떤 핸들러를 호출할지 결정
descriptionAI 모델도구의 용도를 이해하고 적절히 선택
input_schemaAI 모델 + 런타임올바른 형태의 입력 생성 + 검증
required_permission권한 시스템실행 전 승인 여부 판단

namedescription&'static str인 것은 이 메타데이터가 컴파일 타임에 확정됨을 의미한다. 런타임에 도구를 동적으로 추가할 수 없다 — MCP를 제외하면.

18개 MVP 도구의 전체 목록

mvp_tool_specs() 함수가 반환하는 전체 도구 목록을 권한별로 분류하면:

ReadOnly (읽기 전용, 7개)

도구설명주요 파라미터
read_file파일 읽기path(필수), offset, limit
glob_search패턴 기반 파일 검색pattern(필수), path
grep_search정규식 내용 검색pattern(필수), 10+ 선택적 플래그
WebFetchURL 내용 가져오기url(필수, URI), prompt(필수)
WebSearch웹 검색query(필수, min 2자), 도메인 필터
Skill스킬 로드skill(필수), args
ToolSearch도구 발견query(필수)

WorkspaceWrite (워크스페이스 수정, 4개)

도구설명주요 파라미터
write_file파일 생성/수정path(필수), content(필수)
edit_file텍스트 교체path, old_string, new_string(모두 필수)
TodoWrite작업 목록 관리배열: content, activeForm, status
NotebookEditJupyter 노트북 편집notebook_path(필수), cell_id, edit_mode

DangerFullAccess (전체 접근, 3개)

도구설명주요 파라미터
bash셸 명령 실행command(필수), timeout, run_in_background
Agent서브 에이전트 실행description(필수), prompt(필수), model
REPL코드 실행code(필수), language(필수)

이 분류가 보여주는 설계 원칙은 명확하다: 읽기는 자유롭게, 쓰기는 워크스페이스 내에서, 실행은 최고 권한으로. bashDangerFullAccess인 이유는 자명하다 — rm -rf /도 가능하기 때문이다.

JSON Schema: AI를 위한 입력 가이드

bash 도구의 스키마를 예로 보면:

ToolSpec {
    name: "bash",
    description: "Execute a shell command in the current workspace.",
    input_schema: json!({
        "type": "object",
        "properties": {
            "command": { "type": "string" },
            "timeout": { "type": "integer", "minimum": 1 },
            "description": { "type": "string" },
            "run_in_background": { "type": "boolean" },
            "dangerouslyDisableSandbox": { "type": "boolean" }
        },
        "required": ["command"],
        "additionalProperties": false
    }),
    required_permission: PermissionMode::DangerFullAccess,
}

additionalProperties: false가 모든 도구에 공통적으로 적용된다. AI가 스키마에 정의되지 않은 필드를 생성하면 검증 단계에서 거부된다. 이것이 AI의 환각(hallucination)에 대한 구조적 방어다.

grep_search의 스키마는 특히 복잡해서, AI에게 강력한 검색 기능을 노출한다:

{
    "properties": {
        "pattern": { "type": "string" },
        "path": { "type": "string" },
        "glob": { "type": "string" },
        "-B": { "type": "number" },     // before context lines
        "-A": { "type": "number" },     // after context lines
        "-C": { "type": "number" },     // both directions
        "-i": { "type": "boolean" },    // case insensitive
        "multiline": { "type": "boolean" },
        "output_mode": { "enum": ["content", "files_with_matches", "count"] },
        "head_limit": { "type": "number" },
        "offset": { "type": "number" }
    },
    "required": ["pattern"]
}

2. 도구 디스패치: match 문의 단순한 힘

pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
    match name {
        "bash"        => from_value::<BashCommandInput>(input).and_then(run_bash),
        "read_file"   => from_value::<ReadFileInput>(input).and_then(run_read_file),
        "write_file"  => from_value::<WriteFileInput>(input).and_then(run_write_file),
        "edit_file"   => from_value::<EditFileInput>(input).and_then(run_edit_file),
        "glob_search" => from_value::<GlobSearchInputValue>(input).and_then(run_glob_search),
        "grep_search" => from_value::<GrepSearchInput>(input).and_then(run_grep_search),
        // ... 12개 더
        _ => Err(format!("unsupported tool: {name}")),
    }
}

match 문은 의도적으로 단순하다. 트레이트 객체 기반 레지스트리나 HashMap 디스패치 대신 직접적인 패턴 매칭을 사용한 이유가 있다:

  1. 컴파일 타임 완전성 검증: 새 도구를 추가하면 이 match에도 추가해야 하고, 빼먹으면 _ 분기로 빠진다. 타입 시스템이 아닌 코드 리뷰에서 잡아야 하지만, 중앙 집중식이라 놓치기 어렵다.

  2. 제로 런타임 오버헤드: 가상 함수 테이블(vtable) 없이 직접 함수 호출. 도구 실행 자체가 I/O 바운드이므로 디스패치 성능은 중요하지 않지만, Rust의 제로코스트 원칙에 부합한다.

  3. 타입 안전 역직렬화: from_value::<T>(input)이 JSON을 구체적인 입력 타입으로 변환한다. 각 도구가 자신만의 입력 구조체를 갖는다:

struct ReadFileInput { path: String, offset: Option<usize>, limit: Option<usize> }
struct EditFileInput { path: String, old_string: String, new_string: String, replace_all: Option<bool> }
struct GrepSearchInput { pattern: String, path: Option<String>, glob: Option<String>, /* 8+ more */ }

and_then 체인은 역직렬화 실패 시 실행 자체를 건너뛴다. Err가 전파되어 "입력 형식 오류" 에러가 AI에게 반환된다.


3. 권한 모델: 3단계 + 에스컬레이션

PermissionMode의 순서

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PermissionMode {
    ReadOnly,           // 가장 낮은 권한
    WorkspaceWrite,     // 워크스페이스 수정 가능
    DangerFullAccess,   // 모든 작업 가능
    Prompt,             // 사용자 확인 필요
    Allow,              // 모든 것 자동 승인
}

PartialOrdOrd를 derive한 것이 이 열거형의 핵심이다. Rust는 열거형 변형의 선언 순서를 비교 순서로 사용한다. 따라서:

ReadOnly < WorkspaceWrite < DangerFullAccess < Prompt < Allow

이 순서 덕분에 current_mode >= required_mode라는 단순한 비교로 권한 충분 여부를 판단할 수 있다.

authorize(): 권한 결정의 전체 흐름

pub fn authorize(
    &self,
    tool_name: &str,
    input: &str,
    mut prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
    let current_mode = self.active_mode();
    let required_mode = self.required_mode_for(tool_name);

    // 1. 빠른 경로: 현재 권한이 충분하면 즉시 승인
    if current_mode == PermissionMode::Allow || current_mode >= required_mode {
        return PermissionOutcome::Allow;
    }

    // 2. 에스컬레이션 가능한 두 경우만 사용자에게 물어봄
    if current_mode == PermissionMode::Prompt
        || (current_mode == PermissionMode::WorkspaceWrite
            && required_mode == PermissionMode::DangerFullAccess)
    {
        return match prompter.as_mut() {
            Some(prompter) => match prompter.decide(&request) {
                PermissionPromptDecision::Allow => PermissionOutcome::Allow,
                PermissionPromptDecision::Deny { reason } =>
                    PermissionOutcome::Deny { reason },
            },
            None => PermissionOutcome::Deny {
                reason: format!("tool '{}' requires approval...", tool_name),
            },
        };
    }

    // 3. 나머지: 거부
    PermissionOutcome::Deny {
        reason: format!("tool '{}' requires {} permission; current mode is {}",
            tool_name, required_mode.as_str(), current_mode.as_str()),
    }
}

이 알고리즘의 핵심은 에스컬레이션 경로가 두 개뿐이라는 것이다:

허용되는 에스컬레이션:
  Prompt → 모든 도구 (사용자 결정에 따라)
  WorkspaceWrite → DangerFullAccess (bash, Agent 등)

거부되는 에스컬레이션:
  ReadOnly → WorkspaceWrite (파일 쓰기 불가)
  ReadOnly → DangerFullAccess (bash 실행 불가)

ReadOnly에서 WorkspaceWrite로의 에스컬레이션이 불가능한 것이 의미심장하다. 읽기 전용 환경에서는 사용자에게 물어보지도 않고 파일 수정을 차단한다. 이는 "실수로 허용하는 것"을 원천 봉쇄하는 보수적 설계다.

권한 접근성 매트릭스

각 모드에서 어떤 도구에 접근할 수 있는지 정리하면:

현재 모드ReadOnly 도구WorkspaceWrite 도구DangerFullAccess 도구
ReadOnly✅ 자동 승인❌ 거부❌ 거부
WorkspaceWrite✅ 자동 승인✅ 자동 승인❓ 사용자 확인
DangerFullAccess✅ 자동 승인✅ 자동 승인✅ 자동 승인
Prompt❓ 사용자 확인❓ 사용자 확인❓ 사용자 확인
Allow✅ 자동 승인✅ 자동 승인✅ 자동 승인

WorkspaceWrite → DangerFullAccess만 "❓ 사용자 확인"인 것이 이 모델의 특징이다. 대부분의 개발 작업(파일 읽기/쓰기)은 자동으로 처리하되, bashAgent 같은 위험한 도구만 확인을 요청한다.


4. 파일 작업: 가장 자주 쓰이는 도구들

read_file: 줄 단위 슬라이싱

pub fn read_file(path: &str, offset: Option<usize>, limit: Option<usize>)
    -> io::Result<ReadFileOutput> {
    let absolute_path = normalize_path(path)?;
    let content = fs::read_to_string(&absolute_path)?;
    let lines: Vec<&str> = content.lines().collect();
    let start_index = offset.unwrap_or(0).min(lines.len());
    let end_index = limit.map_or(lines.len(), |limit| {
        start_index.saturating_add(limit).min(lines.len())
    });
    let selected = lines[start_index..end_index].join("\n");
    // ...
}

offsetlimit줄 단위인 것이 중요하다. 바이트 단위가 아니라 줄 번호로 범위를 지정한다. AI가 "100번째 줄부터 20줄"을 요청할 수 있어, 대용량 파일에서도 필요한 부분만 읽을 수 있다.

saturating_add(limit)start_index + limitusize::MAX를 초과해도 패닉하지 않게 한다. .min(lines.len())은 파일 끝을 넘어가지 않게 보장한다.

edit_file: 세 겹의 안전 검증

pub fn edit_file(path: &str, old_string: &str, new_string: &str, replace_all: bool)
    -> io::Result<EditFileOutput> {
    let original = fs::read_to_string(&absolute_path)?;

    // 검증 1: 같은 문자열로 교체하는 것 방지
    if old_string == new_string {
        return Err(io::Error::new(io::ErrorKind::InvalidInput, ...));
    }

    // 검증 2: 대상 문자열이 존재하는지 확인
    if !original.contains(old_string) {
        return Err(io::Error::new(io::ErrorKind::NotFound, ...));
    }

    // 실행: 첫 번째만 또는 전체 교체
    let updated = if replace_all {
        original.replace(old_string, new_string)
    } else {
        original.replacen(old_string, new_string, 1)
    };
    fs::write(&absolute_path, &updated)?;
}

old_string == new_string 검증이 왜 필요할까? AI가 때때로 "수정" 요청을 생성하지만 실제로는 아무것도 바꾸지 않는 경우가 있다. 이를 조기에 차단하여 불필요한 파일 쓰기와 디스크 I/O를 방지한다.

replacen(old, new, 1)첫 번째 일치만 교체한다. 이것이 기본 동작이며, replace_all: true를 명시적으로 전달해야 전체 교체가 된다. AI가 의도치 않게 모든 일치를 바꾸는 실수를 방지하는 안전한 기본값이다.

write_file: 부모 디렉토리 자동 생성

pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
    let absolute_path = normalize_path_allow_missing(path)?;
    if let Some(parent) = absolute_path.parent() {
        fs::create_dir_all(parent)?;  // mkdir -p 동작
    }
    fs::write(&absolute_path, content)?;
}

create_dir_all()mkdir -p와 동일한 동작을 한다. AI가 src/new_module/main.rs를 생성할 때 src/new_module/ 디렉토리가 없어도 자동으로 만들어진다. 도구 실행이 "디렉토리를 먼저 만들고 → 파일을 쓰는" 두 단계가 아니라 한 단계로 완료된다.

glob_search: 시간순 정렬의 의도

matches.sort_by_key(|path| {
    fs::metadata(path)
        .and_then(|metadata| metadata.modified())
        .ok()
        .map(Reverse)  // 최신 파일이 먼저
});

let truncated = matches.len() > 100;
let filenames = matches.into_iter().take(100)...;

결과를 수정 시간 역순으로 정렬하는 것은 실용적인 선택이다. AI가 "관련 파일을 찾아라"라고 할 때, 가장 최근에 수정된 파일이 현재 작업과 관련이 높을 가능성이 크기 때문이다.

100개 파일 제한은 AI의 컨텍스트 윈도우를 보호한다. 수천 개의 파일 경로를 반환하면 토큰이 낭비된다.

grep_search: 세 가지 출력 모드

let output_mode = input.output_mode.clone()
    .unwrap_or_else(|| String::from("files_with_matches"));

if output_mode == "count" { ... }         // 매칭 수만
else if output_mode == "content" { ... }  // 매칭 라인 + 컨텍스트
else { ... }                              // 파일 경로만 (기본)

기본 모드가 files_with_matches(파일 경로만)인 것이 의미 있다. AI는 보통 "어디에 관련 코드가 있는지"를 먼저 알고 싶어 한다. 실제 내용은 read_file로 따로 읽으면 된다. 이 패턴이 토큰 효율적인 검색 전략이다.


5. Bash 실행: 가장 위험하고 가장 강력한 도구

입력 구조: 놀라운 세밀함

pub struct BashCommandInput {
    pub command: String,
    pub timeout: Option<u64>,                    // 밀리초
    pub description: Option<String>,
    pub run_in_background: Option<bool>,
    pub dangerously_disable_sandbox: Option<bool>,
    pub namespace_restrictions: Option<bool>,
    pub isolate_network: Option<bool>,
    pub filesystem_mode: Option<FilesystemIsolationMode>,
    pub allowed_mounts: Option<Vec<String>>,
}

command 외에 8개의 선택적 파라미터가 있다. 이 중 namespace_restrictions, isolate_network, filesystem_mode, allowed_mounts명령별 샌드박스 제어를 가능하게 한다. 동일한 세션에서 일부 명령은 격리된 환경에서, 다른 명령은 전체 접근으로 실행할 수 있다.

실행 흐름

execute_bash(input):
  1. 현재 디렉토리 확인
  2. 샌드박스 상태 결정 (설정 + 입력 오버라이드)
  3. 백그라운드 여부 확인
     ├─ 백그라운드 → Stdio::null()로 스폰, PID 즉시 반환
     └─ 포그라운드 → 비동기 실행
  4. 타임아웃 처리
     ├─ 시간 내 완료 → stdout/stderr 수집
     └─ 초과 → interrupted: true, "timeout" 반환
  5. BashCommandOutput 반환

타임아웃 처리가 tokio::time::timeout을 사용하는 것이 인상적이다:

match timeout(Duration::from_millis(timeout_ms), command.output()).await {
    Ok(result) => (result?, false),
    Err(_) => {
        return Ok(BashCommandOutput {
            stderr: format!("Command exceeded timeout of {timeout_ms} ms"),
            interrupted: true,
            return_code_interpretation: Some(String::from("timeout")),
            ...
        });
    }
}

타임아웃이 발생해도 패닉이 아닌 정상적인 결과로 반환된다. AI는 "명령이 시간 초과되었다"는 것을 도구 결과로 받고, 다음 행동을 결정할 수 있다.

BashCommandOutput: 풍부한 메타데이터

pub struct BashCommandOutput {
    pub stdout: String,
    pub stderr: String,
    pub interrupted: bool,
    pub background_task_id: Option<String>,
    pub return_code_interpretation: Option<String>,  // "exit_code:N" 또는 "timeout"
    pub sandbox_status: Option<SandboxStatus>,
    // ... 더 많은 필드
}

return_code_interpretation이 문자열인 것은 AI에게 사람이 읽을 수 있는 형태로 결과를 전달하기 위해서다. exit_code:0은 성공, exit_code:1은 에러, timeout은 시간 초과.


6. 샌드박스: 격리의 세 계층

파일 시스템 격리 모드

#[derive(Debug, Clone, Copy, Default)]
pub enum FilesystemIsolationMode {
    Off,              // 격리 없음
    #[default]
    WorkspaceOnly,    // 워크스페이스만 허용
    AllowList,        // 화이트리스트 경로만 허용
}

기본값이 WorkspaceOnly라는 것이 중요하다. 별도 설정 없이도 bash 명령이 워크스페이스 밖의 파일에 접근하는 것이 제한된다.

Linux unshare 기반 격리

Linux에서 Claw Code는 커널의 네임스페이스 기능을 직접 활용한다:

pub fn build_linux_sandbox_command(command: &str, cwd: &Path, status: &SandboxStatus)
    -> Option<LinuxSandboxCommand> {
    let mut args = vec![
        "--user",           // 사용자 네임스페이스 (root 없이 격리)
        "--map-root-user",  // 컨테이너 내 UID 0 = 호스트의 현재 사용자
        "--mount",          // 마운트 네임스페이스 (파일 시스템 격리)
        "--ipc",            // IPC 네임스페이스 (프로세스 간 통신 격리)
        "--pid",            // PID 네임스페이스 (프로세스 ID 격리)
        "--uts",            // UTS 네임스페이스 (호스트명 격리)
        "--fork",           // 별도 프로세스로 분리
    ];
    if status.network_active {
        args.push("--net"); // 네트워크 네임스페이스 (네트워크 격리)
    }
    args.extend(vec!["sh", "-lc", command]);

    let env = vec![
        ("HOME", cwd.join(".sandbox-home").display().to_string()),
        ("TMPDIR", cwd.join(".sandbox-tmp").display().to_string()),
    ];
    // ...
}

최종적으로 실행되는 명령은 이렇게 된다:

unshare --user --map-root-user --mount --ipc --pid --uts --fork [--net] sh -lc "$command"

각 네임스페이스의 역할을 정리하면:

네임스페이스격리 대상효과
--user사용자 IDroot 권한 없이도 격리 가능
--mount파일 시스템호스트 마운트 포인트 숨김
--ipc프로세스 간 통신공유 메모리 격리
--pid프로세스 ID다른 프로세스 볼 수 없음
--uts호스트명격리된 호스트명
--net네트워크외부 네트워크 차단 (선택적)

HOMETMPDIR.sandbox-home.sandbox-tmp로 재설정하는 것도 중요하다. 명령이 ~/.bashrc/tmp의 민감한 데이터에 접근하는 것을 방지한다.

컨테이너 환경 감지

이미 Docker나 Kubernetes 안에서 실행 중이면 unshare를 중첩 실행할 수 없다. 이를 위한 감지 로직:

pub fn detect_container_environment_from(inputs: SandboxDetectionInputs)
    -> ContainerEnvironment {
    let mut markers = Vec::new();

    // 1. 파일 기반 감지
    if inputs.dockerenv_exists { markers.push("/.dockerenv"); }
    if inputs.containerenv_exists { markers.push("/run/.containerenv"); }

    // 2. 환경변수 기반 감지
    for (key, value) in inputs.env_pairs {
        if matches!(key.to_ascii_lowercase().as_str(),
            "container" | "docker" | "podman" | "kubernetes_service_host")
        && !value.is_empty() {
            markers.push(format!("env:{key}={value}"));
        }
    }

    // 3. cgroup 기반 감지
    if let Some(cgroup) = inputs.proc_1_cgroup {
        for needle in ["docker", "containerd", "kubepods", "podman", "libpod"] {
            if cgroup.contains(needle) {
                markers.push(format!("/proc/1/cgroup:{needle}"));
            }
        }
    }

    ContainerEnvironment {
        in_container: !markers.is_empty(),
        markers,
    }
}

세 가지 감지 전략을 병렬로 사용하는 것이 견고하다. Docker는 /.dockerenv를 남기고, Kubernetes는 KUBERNETES_SERVICE_HOST 환경변수를 설정하며, containerd는 cgroup 경로에 흔적을 남긴다. 어느 하나만 의존하면 특정 환경에서 감지에 실패할 수 있다.

우아한 퇴보(Graceful Degradation)

pub fn resolve_sandbox_status_for_request(request: &SandboxRequest, cwd: &Path)
    -> SandboxStatus {
    let namespace_supported = cfg!(target_os = "linux") && command_exists("unshare");

    let mut fallback_reasons = Vec::new();
    if request.namespace_restrictions && !namespace_supported {
        fallback_reasons.push(
            "namespace isolation unavailable (requires Linux with `unshare`)"
        );
    }
    // ...
}

macOS나 unshare가 없는 환경에서는 샌드박스가 비활성화되면서 이유를 기록한다. 에러로 실패하는 대신 기능을 줄여서 동작한다. fallback_reasons는 사용자에게 "왜 샌드박스가 꺼져 있는지"를 설명하는 데 사용된다.


7. MCP: 외부 도구 생태계 통합

Model Context Protocol이란

MCP(Model Context Protocol)는 AI 에이전트가 외부 도구 서버와 통신하는 표준 프로토콜이다. Claw Code는 MCP를 통해 내장 18개 도구 외에 무한한 외부 도구를 추가할 수 있다.

도구 네이밍 규칙: 네임스페이스 충돌 방지

pub fn mcp_tool_name(server_name: &str, tool_name: &str) -> String {
    format!("{}{}",
        mcp_tool_prefix(server_name),
        normalize_name_for_mcp(tool_name))
}

pub fn normalize_name_for_mcp(name: &str) -> String {
    name.chars()
        .map(|ch| match ch {
            'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' => ch,
            _ => '_',  // 특수문자를 언더스코어로 대체
        })
        .collect()
}

서버 "github.com"의 도구 "list_repos"는 mcp__github_com__list_repos가 된다. mcp__ 접두사가 내장 도구와의 네임스페이스 충돌을 원천 봉쇄한다. 아무리 많은 MCP 서버를 연결해도, bashread_file 같은 내장 도구를 가로챌 수 없다.

McpServerManager: 지연 초기화

pub struct McpServerManager {
    servers: BTreeMap<String, ManagedMcpServer>,
    tool_index: BTreeMap<String, ToolRoute>,
    next_request_id: u64,
}

struct ManagedMcpServer {
    bootstrap: McpClientBootstrap,
    process: Option<McpStdioProcess>,  // None이면 아직 시작 안 됨
    initialized: bool,
}

process: Option<McpStdioProcess>이 핵심이다. 서버 프로세스는 처음 도구가 호출될 때 스폰된다. 설정에 10개 MCP 서버가 있어도, 실제로 사용되는 서버만 프로세스를 차지한다.

discover_tools(): 페이징 기반 도구 발견

pub async fn discover_tools(&mut self)
    -> Result<Vec<ManagedMcpTool>, McpServerManagerError> {
    for server_name in server_names {
        self.ensure_server_ready(&server_name).await?;

        let mut cursor = None;
        loop {
            let response = process.list_tools(request_id,
                Some(McpListToolsParams { cursor: cursor.clone() })
            ).await?;

            for tool in result.tools {
                let qualified_name = mcp_tool_name(&server_name, &tool.name);
                self.tool_index.insert(qualified_name.clone(),
                    ToolRoute { server_name: server_name.clone(),
                                raw_name: tool.name.clone() });
                discovered_tools.push(/* ... */);
            }

            match result.next_cursor {
                Some(next) => cursor = Some(next),
                None => break,  // 더 이상 페이지 없음
            }
        }
    }
    Ok(discovered_tools)
}

커서 기반 페이징은 도구가 수천 개인 서버를 처리할 수 있게 한다. next_cursorNone이면 모든 도구를 발견한 것이다.

call_tool(): 이름 역매핑

pub async fn call_tool(&mut self, qualified_tool_name: &str, arguments: Option<JsonValue>)
    -> Result<JsonRpcResponse<McpToolCallResult>, McpServerManagerError> {
    let route = self.tool_index.get(qualified_tool_name)
        .ok_or_else(|| McpServerManagerError::UnknownTool { ... })?;

    let response = process.call_tool(request_id, McpToolCallParams {
        name: route.raw_name,  // 정규화 이전의 원래 이름으로 복원
        arguments,
        meta: None,
    }).await?;
    Ok(response)
}

route.raw_name이 핵심이다. mcp__github_com__list_repos라는 정규화된 이름을 다시 list_repos로 복원하여 서버에 전달한다. 네이밍 규칙은 Claw Code 내부에서만 유효하며, MCP 서버는 자신의 원래 도구 이름만 알면 된다.

JSON-RPC 프로토콜: Stdio 기반

MCP 서버와의 통신은 JSON-RPC 2.0 프로토콜을 사용한다:

// 요청
pub struct JsonRpcRequest<T = JsonValue> {
    pub jsonrpc: String,  // "2.0"
    pub id: JsonRpcId,
    pub method: String,   // "tools/list", "tools/call", "initialize"
    pub params: Option<T>,
}

// 응답
pub struct JsonRpcResponse<T = JsonValue> {
    pub jsonrpc: String,
    pub id: JsonRpcId,
    pub result: Option<T>,
    pub error: Option<JsonRpcError>,
}

Stdio 전송 계층은 HTTP와 유사한 프레이밍을 사용한다:

pub async fn read_frame(&mut self) -> io::Result<Vec<u8>> {
    // "Content-Length: N\r\n\r\n" 헤더 파싱
    // 이후 정확히 N 바이트 읽기
}

fn encode_frame(payload: &[u8]) -> Vec<u8> {
    let header = format!("Content-Length: {}\r\n\r\n", payload.len());
    let mut framed = header.into_bytes();
    framed.extend_from_slice(payload);
    framed
}

Content-Length 헤더로 프레임 경계를 결정하는 것은 LSP(Language Server Protocol)와 동일한 방식이다. MCP가 LSP의 통신 프로토콜을 그대로 채용한 것이다.


8. 전체 실행 파이프라인 종합

AI가 bash 도구를 호출할 때의 전체 경로를 추적하면:

AI 응답: ToolUse("bash", {"command": "cargo test"})
    │
    ▼
1. ConversationRuntime.run_turn()에서 ToolUse 블록 감지
    │
    ▼
2. PermissionPolicy.authorize("bash", input, prompter)
   └─ required: DangerFullAccess
   └─ current: WorkspaceWrite → 에스컬레이션 → 사용자에게 물어봄
   └─ 사용자 승인 → Allow
    │
    ▼
3. HookRunner.run_pre_tool_use("bash", input)
   └─ 등록된 훅 실행 (exit 0 → 허용)
    │
    ▼
4. ToolExecutor.execute("bash", input)
   └─ execute_tool("bash", value)
   └─ from_value::<BashCommandInput>(value)
   └─ run_bash(input)
       │
       ▼
5. 샌드박스 상태 결정
   └─ Linux + unshare 사용 가능 → 격리 활성화
       │
       ▼
6. unshare --user --map-root-user --mount --ipc --pid --uts --fork \
     sh -lc "cargo test"
   └─ 타임아웃 내 완료 → stdout/stderr 수집
    │
    ▼
7. HookRunner.run_post_tool_use("bash", input, output, false)
    │
    ▼
8. ConversationMessage::tool_result("bash", output, false)
   └─ 세션에 추가 → 다음 API 호출에 포함

8단계를 거치지만, 핵심은 2단계(권한)와 5-6단계(샌드박스)다. 이 두 관문이 AI 에이전트의 행동을 안전한 범위 내로 제한한다.


마치며: 안전한 실행의 설계 원칙

Claw Code의 도구 시스템을 관통하는 설계 원칙을 정리하면:

도구 정의는 선언적이다. ToolSpec의 네 필드(이름, 설명, 스키마, 권한)만으로 도구가 완전히 서술된다. 새 도구를 추가하려면 spec을 정의하고 handler를 match에 등록하면 된다.

권한은 구조적으로 보수적이다. ReadOnly → WorkspaceWrite 에스컬레이션이 불가능한 것처럼, 안전하지 않은 경로는 코드 레벨에서 차단된다.

격리는 계층적이다. 권한 모델(논리적 격리) → 훅(프로세스 레벨 검증) → 샌드박스(OS 레벨 격리). 한 계층이 실패해도 다음 계층이 방어한다.

MCP는 네임스페이스로 안전하다. mcp__ 접두사가 외부 도구와 내장 도구 사이의 경계를 보장한다. 외부 도구가 아무리 많아도 내장 도구의 동작에 영향을 줄 수 없다.

다음 편에서는 Rust 프로덕션 런타임의 대응물인 Python 포팅 워크스페이스를 분석한다. 같은 아키텍처가 다른 언어에서 어떻게 표현되는지, 그리고 포팅 자체를 추적하는 메타 시스템이 어떻게 동작하는지 살펴본다.


실습 가이드

# 1. 도구 목록과 권한 확인
cd rust && grep -n "ToolSpec {" crates/tools/src/lib.rs

# 2. 권한 모드별 접근 가능 도구 실험
# ReadOnly 모드로 실행하여 write_file 거부 확인
CLAW_PERMISSION_MODE=readonly cargo run -p rusty-claude-cli

# 3. 샌드박스 동작 확인
# unshare가 설치되어 있는지 확인
which unshare && echo "sandbox available"

# 4. MCP 서버 설정 예시
cat > .claude/mcp_settings.json << 'EOF'
{
  "servers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
    }
  }
}
EOF

# 5. grep_search의 세 가지 출력 모드 비교
cargo test -p runtime file_ops -- --nocapture

이 글은 [Claw Code 깊이 읽기] 시리즈의 5편입니다.

시리즈 목차:
1. 프로젝트 배경과 아키텍처 개요
2. Rust 워크스페이스 아키텍처 심층 분석
3. API 통신과 SSE 스트리밍 구현
4. 대화 런타임과 세션 관리
5. 도구 시스템과 권한 모델 ← 현재 글
6. Python 포팅 워크스페이스 분석
7. 테스팅 전략과 패리티 추적
8. TUI 개선 로드맵과 미래 방향

태그: #ClawCode #Rust #도구시스템 #권한모델 #MCP #샌드박스 #unshare #AI보안 #JsonRPC

profile
꿈꾸는 개발자

0개의 댓글