
AI 에이전트가 "행동"한다는 것은, 결국 도구를 실행한다는 것이다. 그 실행의 전체 경로를 추적한다.
LLM은 텍스트를 생성한다. 그 자체로는 유용하지만, 파일을 수정하거나 코드를 실행할 수는 없다. LLM이 "에이전트"가 되려면 현실 세계와 상호작용하는 도구(tool)가 필요하다.
Claw Code의 도구 시스템은 세 가지 질문에 답해야 한다:
1. 어떤 도구가 있는가? — ToolSpec과 mvp_tool_specs()
2. 이 도구를 실행해도 되는가? — PermissionPolicy와 3단계 권한
3. 어떻게 안전하게 실행하는가? — 샌드박스와 MCP 격리
이번 편에서는 이 세 질문을 따라가며, 도구 정의부터 실행, 권한 검증, 외부 도구 통합까지를 코드 레벨에서 분석한다.
pub struct ToolSpec {
pub name: &'static str,
pub description: &'static str,
pub input_schema: Value, // JSON Schema
pub required_permission: PermissionMode,
}
네 개의 필드가 각각 다른 수신자를 위한 정보를 담고 있다:
| 필드 | 수신자 | 용도 |
|---|---|---|
name | 런타임 디스패처 | 어떤 핸들러를 호출할지 결정 |
description | AI 모델 | 도구의 용도를 이해하고 적절히 선택 |
input_schema | AI 모델 + 런타임 | 올바른 형태의 입력 생성 + 검증 |
required_permission | 권한 시스템 | 실행 전 승인 여부 판단 |
name과 description이 &'static str인 것은 이 메타데이터가 컴파일 타임에 확정됨을 의미한다. 런타임에 도구를 동적으로 추가할 수 없다 — MCP를 제외하면.
mvp_tool_specs() 함수가 반환하는 전체 도구 목록을 권한별로 분류하면:
ReadOnly (읽기 전용, 7개)
| 도구 | 설명 | 주요 파라미터 |
|---|---|---|
read_file | 파일 읽기 | path(필수), offset, limit |
glob_search | 패턴 기반 파일 검색 | pattern(필수), path |
grep_search | 정규식 내용 검색 | pattern(필수), 10+ 선택적 플래그 |
WebFetch | URL 내용 가져오기 | 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 |
NotebookEdit | Jupyter 노트북 편집 | notebook_path(필수), cell_id, edit_mode |
DangerFullAccess (전체 접근, 3개)
| 도구 | 설명 | 주요 파라미터 |
|---|---|---|
bash | 셸 명령 실행 | command(필수), timeout, run_in_background |
Agent | 서브 에이전트 실행 | description(필수), prompt(필수), model |
REPL | 코드 실행 | code(필수), language(필수) |
이 분류가 보여주는 설계 원칙은 명확하다: 읽기는 자유롭게, 쓰기는 워크스페이스 내에서, 실행은 최고 권한으로. bash가 DangerFullAccess인 이유는 자명하다 — rm -rf /도 가능하기 때문이다.
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"]
}
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 디스패치 대신 직접적인 패턴 매칭을 사용한 이유가 있다:
컴파일 타임 완전성 검증: 새 도구를 추가하면 이 match에도 추가해야 하고, 빼먹으면 _ 분기로 빠진다. 타입 시스템이 아닌 코드 리뷰에서 잡아야 하지만, 중앙 집중식이라 놓치기 어렵다.
제로 런타임 오버헤드: 가상 함수 테이블(vtable) 없이 직접 함수 호출. 도구 실행 자체가 I/O 바운드이므로 디스패치 성능은 중요하지 않지만, Rust의 제로코스트 원칙에 부합한다.
타입 안전 역직렬화: 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에게 반환된다.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PermissionMode {
ReadOnly, // 가장 낮은 권한
WorkspaceWrite, // 워크스페이스 수정 가능
DangerFullAccess, // 모든 작업 가능
Prompt, // 사용자 확인 필요
Allow, // 모든 것 자동 승인
}
PartialOrd와 Ord를 derive한 것이 이 열거형의 핵심이다. Rust는 열거형 변형의 선언 순서를 비교 순서로 사용한다. 따라서:
ReadOnly < WorkspaceWrite < DangerFullAccess < Prompt < Allow
이 순서 덕분에 current_mode >= required_mode라는 단순한 비교로 권한 충분 여부를 판단할 수 있다.
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만 "❓ 사용자 확인"인 것이 이 모델의 특징이다. 대부분의 개발 작업(파일 읽기/쓰기)은 자동으로 처리하되, bash나 Agent 같은 위험한 도구만 확인을 요청한다.
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");
// ...
}
offset과 limit이 줄 단위인 것이 중요하다. 바이트 단위가 아니라 줄 번호로 범위를 지정한다. AI가 "100번째 줄부터 20줄"을 요청할 수 있어, 대용량 파일에서도 필요한 부분만 읽을 수 있다.
saturating_add(limit)는 start_index + limit이 usize::MAX를 초과해도 패닉하지 않게 한다. .min(lines.len())은 파일 끝을 넘어가지 않게 보장한다.
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가 의도치 않게 모든 일치를 바꾸는 실수를 방지하는 안전한 기본값이다.
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/ 디렉토리가 없어도 자동으로 만들어진다. 도구 실행이 "디렉토리를 먼저 만들고 → 파일을 쓰는" 두 단계가 아니라 한 단계로 완료된다.
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의 컨텍스트 윈도우를 보호한다. 수천 개의 파일 경로를 반환하면 토큰이 낭비된다.
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로 따로 읽으면 된다. 이 패턴이 토큰 효율적인 검색 전략이다.
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는 "명령이 시간 초과되었다"는 것을 도구 결과로 받고, 다음 행동을 결정할 수 있다.
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은 시간 초과.
#[derive(Debug, Clone, Copy, Default)]
pub enum FilesystemIsolationMode {
Off, // 격리 없음
#[default]
WorkspaceOnly, // 워크스페이스만 허용
AllowList, // 화이트리스트 경로만 허용
}
기본값이 WorkspaceOnly라는 것이 중요하다. 별도 설정 없이도 bash 명령이 워크스페이스 밖의 파일에 접근하는 것이 제한된다.
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 | 사용자 ID | root 권한 없이도 격리 가능 |
--mount | 파일 시스템 | 호스트 마운트 포인트 숨김 |
--ipc | 프로세스 간 통신 | 공유 메모리 격리 |
--pid | 프로세스 ID | 다른 프로세스 볼 수 없음 |
--uts | 호스트명 | 격리된 호스트명 |
--net | 네트워크 | 외부 네트워크 차단 (선택적) |
HOME과 TMPDIR을 .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 경로에 흔적을 남긴다. 어느 하나만 의존하면 특정 환경에서 감지에 실패할 수 있다.
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는 사용자에게 "왜 샌드박스가 꺼져 있는지"를 설명하는 데 사용된다.
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 서버를 연결해도, bash나 read_file 같은 내장 도구를 가로챌 수 없다.
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 서버가 있어도, 실제로 사용되는 서버만 프로세스를 차지한다.
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_cursor가 None이면 모든 도구를 발견한 것이다.
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 서버는 자신의 원래 도구 이름만 알면 된다.
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의 통신 프로토콜을 그대로 채용한 것이다.
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