
2편에서 permission_mode와 allowed_tools/disallowed_tools를 다뤘습니다. 이 정도만으로도 기본적인 제어는 가능합니다.
options = ClaudeAgentOptions(
permission_mode="bypassPermissions",
allowed_tools=["Read", "Write"],
disallowed_tools=["Bash"],
)
하지만 이건 전부 허용하거나 전부 차단하는 방식입니다. 실제로는 이런 요구가 생깁니다:
rm -rf는 차단하고 싶다/etc/ 아래는 쓰지 못하게 하고 싶다이런 세밀한 제어를 가능하게 해주는 것이 can_use_tool 콜백입니다.
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을 설정하면, Claude가 도구를 호출할 때마다 SDK와 CLI 사이에 권한 협상이 일어납니다.
Claude가 도구 사용 결정
│
▼
CLI → SDK: "Bash 도구를 이 입력으로 실행해도 될까?"
│ (tool_name, input, context 전달)
│
▼
SDK: can_use_tool 콜백 호출
│
├── PermissionResultAllow 반환
│ → CLI: 도구 실행 (입력값 수정 가능)
│
└── PermissionResultDeny 반환
→ CLI: 도구 실행 거부
→ interrupt=True면 전체 세션 중단
중요한 제약이 있습니다. 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_name | str | 도구 이름 ("Bash", "Write", "Read" 등) |
tool_input | dict[str, Any] | 도구에 전달될 입력값 |
context | ToolPermissionContext | 추가 컨텍스트 정보 |
async def my_permission_check(
tool_name: str,
tool_input: dict[str, Any],
context: ToolPermissionContext,
) -> PermissionResult:
# 여기서 허용/거부를 결정
return PermissionResultAllow()
@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) |
suggestions | CLI가 제안하는 권한 규칙 변경 목록 |
tool_use_id | 이 도구 호출의 고유 ID — 같은 AssistantMessage 내 여러 도구 호출을 구분 |
agent_id | 서브 에이전트에서 호출된 경우, 해당 에이전트의 ID |
suggestions는 CLI가 "이 도구를 앞으로 자동 허용하는 규칙을 추가하면 어떨까요?"라고 제안하는 것입니다. 대화형 CLI에서 "Always allow" 버튼을 누르는 것과 같은 개념입니다.
@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, Edit | file_path |
| 명령 래핑 | Bash | command (예: 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()
@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 반응 | 다른 방법 시도 | 실행 종료 |
| 용도 | 부드러운 제한 | 긴급 차단 |
PermissionResultAllow의 updated_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"만 사용하는 것을 강력히 권장합니다. 다른 값을 쓰면 디스크에 설정이 영구 저장되어 의도치 않은 부작용이 생길 수 있습니다.
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()
모든 도구 호출을 로깅하면서 허용합니다.
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()
모든 파일 작업을 특정 디렉토리 안으로 제한합니다.
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()
무한 루프 명령이 서버를 죽이지 않도록 모든 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()
실전에서는 여러 패턴을 순서대로 적용합니다.
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)
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() | ClaudeSDKClient | |
|---|---|---|
| 통신 방식 | 단방향 | 양방향 |
can_use_tool | 사용 불가 | 사용 가능 |
| 세션 중 권한 변경 | 불가 | set_permission_mode() |
| 대화 이어가기 | 제한적 | 자유 |
| 인터럽트 | 불가 | interrupt() |
| 복잡도 | 낮음 | 높음 |
판단 기준:
query()can_use_tool이 필요하거나 대화형이면 → ClaudeSDKClientcan_use_tool 콜백은 Claude Agent를 프로덕션 환경에서 안전하게 운영하기 위한 핵심 메커니즘입니다.
기억할 3가지:
PermissionResultAllow(updated_input=...) — 도구 입력값을 가로채서 수정 가능PermissionResultDeny(interrupt=True) — 위험 감지 시 즉시 세션 중단ClaudeSDKClient 필수 — query()로는 사용할 수 없음다음 편에서는 이 권한 시스템과 함께 쓰이는 훅(Hook) 시스템을 다룹니다. 권한이 "허용/거부"라면, 훅은 "관찰/개입/로깅"입니다.
전체 소스 코드는 GitHub에서 확인할 수 있습니다:
👉 GitHub 레포지토리 링크