API Key 없이 Claude Agent 서버 만들기! #5 - can_use_tool 콜백으로 에이전트를 통제하기

조현상·2026년 4월 2일

ClaudeCode

목록 보기
13/17
post-thumbnail

들어가며

2편에서 permission_modeallowed_tools/disallowed_tools를 다뤘습니다. 이 정도만으로도 기본적인 제어는 가능합니다.

options = ClaudeAgentOptions(
    permission_mode="bypassPermissions",
    allowed_tools=["Read", "Write"],
    disallowed_tools=["Bash"],
)

하지만 이건 전부 허용하거나 전부 차단하는 방식입니다. 실제로는 이런 요구가 생깁니다:

  • Bash는 허용하되, rm -rf는 차단하고 싶다
  • Write는 허용하되, /etc/ 아래는 쓰지 못하게 하고 싶다
  • 도구 입력값을 가로채서 수정하고 싶다 (경로 변환, 입력 필터링 등)
  • 도구 호출을 로깅하고 싶다

이런 세밀한 제어를 가능하게 해주는 것이 can_use_tool 콜백입니다.


권한 제어의 3단계

SDK의 권한 시스템은 3단계로 구성됩니다. 바깥 레이어가 안쪽 레이어보다 먼저 적용됩니다.

┌───────────────────────────────────────────────────┐
│  1단계: permission_mode                            │
│  "bypassPermissions" / "plan" / "default" / ...   │
│                                                   │
│  ┌────────────────────────────────────────────┐   │
│  │  2단계: allowed_tools / disallowed_tools    │   │
│  │  화이트리스트 / 블랙리스트                       │   │
│  │                                            │   │
│  │  ┌─────────────────────────────────────┐   │   │
│  │  │  3단계: can_use_tool 콜백             │   │   │
│  │  │  도구 호출 하나하나를 함수로 제어           │   │   │
│  │  └─────────────────────────────────────┘   │   │
│  └────────────────────────────────────────────┘   │
└───────────────────────────────────────────────────┘

1편~2편에서 1단계와 2단계를 다뤘고, 이 글에서는 3단계를 다룹니다.


can_use_tool 콜백의 동작 원리

내부 흐름

can_use_tool을 설정하면, Claude가 도구를 호출할 때마다 SDK와 CLI 사이에 권한 협상이 일어납니다.

Claude가 도구 사용 결정
      │
      ▼
CLI → SDK: "Bash 도구를 이 입력으로 실행해도 될까?"
      │     (tool_name, input, context 전달)
      │
      ▼
SDK: can_use_tool 콜백 호출
      │
      ├── PermissionResultAllow 반환
      │     → CLI: 도구 실행 (입력값 수정 가능)
      │
      └── PermissionResultDeny 반환
            → CLI: 도구 실행 거부
            → interrupt=True면 전체 세션 중단

제약 사항: ClaudeSDKClient 필수

중요한 제약이 있습니다. can_use_tool양방향 통신이 필요하기 때문에, 단방향인 query() 함수로는 사용할 수 없습니다. 반드시 ClaudeSDKClient를 써야 합니다.

# ❌ 이렇게 하면 안 됨 — query()는 단방향
async for msg in query(prompt="...", options=ClaudeAgentOptions(can_use_tool=my_callback)):
    pass  # ValueError 발생!

# ✅ ClaudeSDKClient를 사용해야 함
async with ClaudeSDKClient(options=ClaudeAgentOptions(can_use_tool=my_callback)) as client:
    await client.connect("안녕")
    async for msg in client.receive_messages():
        pass

콜백 함수 시그니처

CanUseTool = Callable[
    [str, dict[str, Any], ToolPermissionContext],
    Awaitable[PermissionResult]
]

3개의 인자를 받고, PermissionResult를 반환하는 async 함수입니다.

인자타입설명
tool_namestr도구 이름 ("Bash", "Write", "Read" 등)
tool_inputdict[str, Any]도구에 전달될 입력값
contextToolPermissionContext추가 컨텍스트 정보
async def my_permission_check(
    tool_name: str,
    tool_input: dict[str, Any],
    context: ToolPermissionContext,
) -> PermissionResult:
    # 여기서 허용/거부를 결정
    return PermissionResultAllow()

ToolPermissionContext — 콜백에 전달되는 컨텍스트

@dataclass
class ToolPermissionContext:
    signal: Any | None = None
    suggestions: list[PermissionUpdate] = field(default_factory=list)
    tool_use_id: str | None = None
    agent_id: str | None = None
필드설명
signal미래 확장용 (현재 항상 None)
suggestionsCLI가 제안하는 권한 규칙 변경 목록
tool_use_id이 도구 호출의 고유 ID — 같은 AssistantMessage 내 여러 도구 호출을 구분
agent_id서브 에이전트에서 호출된 경우, 해당 에이전트의 ID

suggestions는 CLI가 "이 도구를 앞으로 자동 허용하는 규칙을 추가하면 어떨까요?"라고 제안하는 것입니다. 대화형 CLI에서 "Always allow" 버튼을 누르는 것과 같은 개념입니다.


응답 타입 1: PermissionResultAllow — 허용

@dataclass
class PermissionResultAllow:
    behavior: Literal["allow"] = "allow"
    updated_input: dict[str, Any] | None = None
    updated_permissions: list[PermissionUpdate] | None = None

기본 허용

return PermissionResultAllow()

입력값을 수정하면서 허용

도구의 입력값을 가로채서 바꿀 수 있습니다. 원래 입력은 변경되지 않고, 수정된 입력으로 도구가 실행됩니다.

async def sandbox_paths(tool_name, tool_input, context):
    if tool_name == "Write":
        original_path = tool_input.get("file_path", "")
        
        # 모든 파일 경로를 /app/workspace/ 안으로 강제
        if not original_path.startswith("/app/workspace/"):
            safe_path = f"/app/workspace/{original_path.lstrip('/')}"
            return PermissionResultAllow(
                updated_input={**tool_input, "file_path": safe_path}
            )
    
    return PermissionResultAllow()

이 패턴으로 할 수 있는 것들:

용도도구수정 대상
경로 격리Write, Read, Editfile_path
명령 래핑Bashcommand (예: timeout 추가)
입력 필터링모든 도구민감 정보 마스킹

권한 규칙을 동적으로 업데이트하면서 허용

허용하면서 동시에 앞으로의 권한 규칙을 변경할 수 있습니다.

async def allow_and_remember(tool_name, tool_input, context):
    if tool_name == "Read":
        return PermissionResultAllow(
            updated_permissions=[
                PermissionUpdate(
                    type="addRules",
                    behavior="allow",
                    rules=[PermissionRuleValue(tool_name="Read")],
                    destination="session",  # 이 세션에서만 유효
                )
            ]
        )
    return PermissionResultAllow()

응답 타입 2: PermissionResultDeny — 거부

@dataclass
class PermissionResultDeny:
    behavior: Literal["deny"] = "deny"
    message: str = ""
    interrupt: bool = False

단순 거부

도구 호출만 거부합니다. Claude는 다른 방법을 시도할 수 있습니다.

async def block_dangerous(tool_name, tool_input, context):
    if tool_name == "Bash":
        command = tool_input.get("command", "")
        if "rm -rf" in command:
            return PermissionResultDeny(
                message="rm -rf 명령은 금지되어 있습니다."
            )
    
    return PermissionResultAllow()

message는 Claude에게 전달됩니다. Claude는 이 메시지를 보고 왜 거부되었는지 이해하고, 다른 접근법을 시도합니다.

거부 + 세션 중단

interrupt=True로 설정하면, 도구 호출을 거부할 뿐만 아니라 전체 세션 실행을 중단합니다.

async def critical_guard(tool_name, tool_input, context):
    if tool_name == "Bash":
        command = tool_input.get("command", "")
        
        # 절대 실행하면 안 되는 패턴
        critical_patterns = ["rm -rf /", ":(){ :|:& };:", "mkfs", "dd if="]
        for pattern in critical_patterns:
            if pattern in command:
                return PermissionResultDeny(
                    message=f"위험한 명령 감지: {pattern}",
                    interrupt=True,  # 즉시 세션 종료
                )
    
    return PermissionResultAllow()

interrupt=False vs interrupt=True:

interrupt=False (기본)interrupt=True
동작이 도구만 거부이 도구 거부 + 세션 중단
Claude 반응다른 방법 시도실행 종료
용도부드러운 제한긴급 차단

PermissionUpdate — 동적 권한 규칙 변경

PermissionResultAllowupdated_permissions에 넣어서 권한 규칙을 실시간으로 변경할 수 있습니다.

@dataclass
class PermissionUpdate:
    type: Literal[
        "addRules",           # 규칙 추가
        "replaceRules",       # 규칙 교체
        "removeRules",        # 규칙 제거
        "setMode",            # 권한 모드 변경
        "addDirectories",     # 작업 디렉토리 추가
        "removeDirectories",  # 작업 디렉토리 제거
    ]
    rules: list[PermissionRuleValue] | None = None
    behavior: Literal["allow", "deny", "ask"] | None = None
    mode: PermissionMode | None = None
    directories: list[str] | None = None
    destination: Literal[
        "userSettings",       # 사용자 전역 설정
        "projectSettings",    # 프로젝트 설정
        "localSettings",      # 로컬 설정
        "session",            # 현재 세션에서만 유효
    ] | None = None

사용 예시

# 1. 특정 도구를 세션 내에서 항상 허용
PermissionUpdate(
    type="addRules",
    behavior="allow",
    rules=[PermissionRuleValue(tool_name="Read")],
    destination="session",
)

# 2. 특정 도구를 세션 내에서 항상 차단
PermissionUpdate(
    type="addRules",
    behavior="deny",
    rules=[PermissionRuleValue(
        tool_name="Bash",
        rule_content="rm *",  # 이 패턴만 차단
    )],
    destination="session",
)

# 3. 권한 모드 자체를 변경
PermissionUpdate(
    type="setMode",
    mode="plan",  # 읽기 전용으로 전환
    destination="session",
)

# 4. 작업 디렉토리 추가
PermissionUpdate(
    type="addDirectories",
    directories=["/app/extra-workspace"],
    destination="session",
)

destination에 따라 규칙의 적용 범위가 달라집니다:

destination범위지속성
"session"현재 세션에서만세션 종료 시 사라짐
"localSettings"이 프로젝트 + 이 머신영구 저장
"projectSettings"이 프로젝트 전체영구 저장
"userSettings"모든 프로젝트영구 저장

서버 환경에서는 "session"만 사용하는 것을 강력히 권장합니다. 다른 값을 쓰면 디스크에 설정이 영구 저장되어 의도치 않은 부작용이 생길 수 있습니다.


실전 패턴 모음

패턴 1: 위험 명령 차단기

BLOCKED_COMMANDS = [
    "rm -rf /", "rm -rf ~", "rm -rf .",
    ":(){ :|:& };:",       # fork bomb
    "mkfs", "dd if=",      # 디스크 포맷/덮어쓰기
    "> /dev/sda",           # 디스크 직접 쓰기
    "chmod 777",            # 과도한 권한 부여
    "curl | sh",            # 원격 스크립트 실행
    "wget | sh",
]

BLOCKED_PATH_PREFIXES = [
    "/etc/", "/usr/", "/bin/", "/sbin/",
    "/var/", "/root/", "/proc/", "/sys/",
]

async def security_guard(tool_name, tool_input, context):
    # Bash 명령 검사
    if tool_name == "Bash":
        command = tool_input.get("command", "")
        for pattern in BLOCKED_COMMANDS:
            if pattern in command:
                return PermissionResultDeny(
                    message=f"보안 정책에 의해 차단됨: {pattern}",
                    interrupt=True,
                )
    
    # 파일 경로 검사
    if tool_name in ("Write", "Edit"):
        file_path = tool_input.get("file_path", "")
        for prefix in BLOCKED_PATH_PREFIXES:
            if file_path.startswith(prefix):
                return PermissionResultDeny(
                    message=f"시스템 디렉토리 쓰기 금지: {prefix}",
                )
    
    return PermissionResultAllow()

패턴 2: 감사 로그 + 허용

모든 도구 호출을 로깅하면서 허용합니다.

import json
import logging
from datetime import datetime

audit_logger = logging.getLogger("audit")

async def audit_and_allow(tool_name, tool_input, context):
    log_entry = {
        "timestamp": datetime.now().isoformat(),
        "tool": tool_name,
        "input": tool_input,
        "tool_use_id": context.tool_use_id,
        "agent_id": context.agent_id,
    }
    audit_logger.info(json.dumps(log_entry, ensure_ascii=False))
    
    return PermissionResultAllow()

패턴 3: 경로 강제 격리

모든 파일 작업을 특정 디렉토리 안으로 제한합니다.

WORKSPACE = "/app/workspace"

async def enforce_workspace(tool_name, tool_input, context):
    path_fields = {
        "Read": "file_path",
        "Write": "file_path",
        "Edit": "file_path",
        "Glob": "path",
        "Grep": "path",
    }
    
    field = path_fields.get(tool_name)
    if field and field in tool_input:
        original = tool_input[field]
        
        # 이미 workspace 안이면 통과
        if original.startswith(WORKSPACE):
            return PermissionResultAllow()
        
        # workspace 밖이면 경로를 강제 변환
        safe = f"{WORKSPACE}/{original.lstrip('/')}"
        return PermissionResultAllow(
            updated_input={**tool_input, field: safe}
        )
    
    return PermissionResultAllow()

패턴 4: Bash 명령에 timeout 자동 추가

무한 루프 명령이 서버를 죽이지 않도록 모든 Bash 호출에 타임아웃을 걸어줍니다.

BASH_TIMEOUT = 30  # 초

async def add_timeout(tool_name, tool_input, context):
    if tool_name == "Bash":
        command = tool_input.get("command", "")
        
        # 이미 timeout이 걸려있으면 건드리지 않음
        if not command.startswith("timeout "):
            safe_command = f"timeout {BASH_TIMEOUT} {command}"
            return PermissionResultAllow(
                updated_input={**tool_input, "command": safe_command}
            )
    
    return PermissionResultAllow()

패턴 5: 패턴 조합 — 여러 가드를 체이닝

실전에서는 여러 패턴을 순서대로 적용합니다.

async def combined_guard(tool_name, tool_input, context):
    # 1차: 감사 로그 (항상)
    log_entry = {
        "tool": tool_name,
        "input_keys": list(tool_input.keys()),
        "agent": context.agent_id,
    }
    audit_logger.info(json.dumps(log_entry))
    
    # 2차: 위험 명령 차단 (Bash)
    if tool_name == "Bash":
        command = tool_input.get("command", "")
        for pattern in BLOCKED_COMMANDS:
            if pattern in command:
                return PermissionResultDeny(
                    message=f"차단됨: {pattern}",
                    interrupt=True,
                )
        # 안전한 명령이면 timeout 추가
        if not command.startswith("timeout "):
            tool_input = {**tool_input, "command": f"timeout 30 {command}"}
    
    # 3차: 경로 격리 (파일 도구)
    if tool_name in ("Write", "Edit", "Read"):
        path = tool_input.get("file_path", "")
        if not path.startswith(WORKSPACE):
            tool_input = {
                **tool_input,
                "file_path": f"{WORKSPACE}/{path.lstrip('/')}",
            }
    
    return PermissionResultAllow(updated_input=tool_input)

전체 사용 예시: ClaudeSDKClient와 함께

can_use_tool을 실제로 사용하는 전체 코드입니다.

import asyncio
from claude_agent_sdk import (
    ClaudeSDKClient,
    ClaudeAgentOptions,
    PermissionResultAllow,
    PermissionResultDeny,
    ToolPermissionContext,
    AssistantMessage,
    ResultMessage,
    TextBlock,
)


async def my_guard(tool_name, tool_input, context):
    """위험 명령 차단 + 경로 격리 + 로깅"""
    print(f"  [권한 체크] {tool_name}: {list(tool_input.keys())}")
    
    # Bash: rm -rf 차단
    if tool_name == "Bash":
        cmd = tool_input.get("command", "")
        if "rm -rf" in cmd:
            return PermissionResultDeny(
                message="rm -rf는 금지입니다.",
                interrupt=True,
            )
    
    # Write/Edit: /app/workspace 안으로 격리
    if tool_name in ("Write", "Edit"):
        path = tool_input.get("file_path", "")
        if not path.startswith("/app/workspace"):
            return PermissionResultAllow(
                updated_input={
                    **tool_input,
                    "file_path": f"/app/workspace/{path.lstrip('/')}",
                }
            )
    
    return PermissionResultAllow()


async def main():
    options = ClaudeAgentOptions(
        permission_mode="bypassPermissions",
        allowed_tools=["Read", "Write", "Edit", "Bash", "Glob"],
        can_use_tool=my_guard,
        cwd="/app/workspace",
        max_turns=10,
    )

    async with ClaudeSDKClient(options=options) as client:
        await client.query("hello.py를 만들어줘")
        
        async for message in client.receive_response():
            if isinstance(message, AssistantMessage):
                for block in message.content:
                    if isinstance(block, TextBlock):
                        print(f"Claude: {block.text}")
            
            elif isinstance(message, ResultMessage):
                print(f"\n완료: {message.num_turns}턴, ${message.total_cost_usd or 0:.4f}")


asyncio.run(main())

query() vs ClaudeSDKClient: 어떤 걸 써야 하나

query()ClaudeSDKClient
통신 방식단방향양방향
can_use_tool사용 불가사용 가능
세션 중 권한 변경불가set_permission_mode()
대화 이어가기제한적자유
인터럽트불가interrupt()
복잡도낮음높음

판단 기준:

  • 도구 호출을 세밀하게 제어할 필요 없으면 → query()
  • can_use_tool이 필요하거나 대화형이면 → ClaudeSDKClient

마치며

can_use_tool 콜백은 Claude Agent를 프로덕션 환경에서 안전하게 운영하기 위한 핵심 메커니즘입니다.

기억할 3가지:

  1. PermissionResultAllow(updated_input=...) — 도구 입력값을 가로채서 수정 가능
  2. PermissionResultDeny(interrupt=True) — 위험 감지 시 즉시 세션 중단
  3. ClaudeSDKClient 필수query()로는 사용할 수 없음

다음 편에서는 이 권한 시스템과 함께 쓰이는 훅(Hook) 시스템을 다룹니다. 권한이 "허용/거부"라면, 훅은 "관찰/개입/로깅"입니다.

전체 소스 코드는 GitHub에서 확인할 수 있습니다:
👉 GitHub 레포지토리 링크

profile
꿈꾸는 개발자

0개의 댓글