这一篇不再从「整体架构」往下扒,而是换个视角——安全工程 / 平台治理。我在翻 Claude Agent SDK 源码的时候,越来越有一个感觉:
当你让 Claude 不再只是「聊天」,而是去读写文件、执行命令、访问网络的时候,如果没有一套像样的防护和审计,很快就会变成一只拿着 root 权限乱跑的脚本小子。
本文就集中聊三块:工具权限系统、Hook 机制、Sandbox 沙箱。目标很朴素:当你让 Claude 去「动手做事」时,尽量做到——事前可控、事中可观、事后可查。
如果你还没看前两篇,更推荐按顺序来:
- 第一篇:整体架构和项目结构 ——
claude-agent-sdk-1- 第二篇:控制协议与 Query/Transport 源码地图 ——
claude-agent-sdk-2看完这两篇再回到安全篇,会对权限 / Hook / Sandbox 在整个系统里的位置更有感觉。
一、为什么要专门拉一篇讲安全?#
如果你只把 Claude 当成一个「问答机器人」,安全面的问题确实不算复杂:更多是 Prompt 过滤和输出合规。但我一开始在使用 Claude Agent SDK 的时候,很快发现一件事:一旦你开始让它「动手」,风险模型就完全不一样了。
- Bash / 文件读写 / 编辑代码 / 访问数据库;
- MCP 工具访问外部系统;
- 在多租户环境里为不同用户、项目引入代理能力;
风险就会迅速升级到:
- 访问了本不该访问的文件或数据库;
- 在错误环境里执行了危险命令;
- 误用高危工具(删库、关服务、打内网…)。
在 SDK 这边,Anthropic 是用三层东西来兜住这块风险的:
- 工具权限系统:决定「这个工具在什么规则下能用」,偏策略层;
- Hook 回调机制:在关键节点拦截、修改、打日志,偏逻辑层;
- Sandbox 沙箱:约束 Bash 的文件系统与网络访问,偏执行环境层。
这三层叠在一起,才有机会支撑起一个「让模型动手」但又不会一言不合就删库 / 打内网的平台。
二、工具权限系统:谁说了算?#
先从最直接的问题切入:「Claude 说要用一个工具,这个决策最后是谁来拍板?」
在 SDK 里,这个「拍板的人」就是 can_use_tool 回调:
CanUseTool = Callable[
[str, dict[str, Any], ToolPermissionContext],
Awaitable[PermissionResult],
]
PermissionResult = PermissionResultAllow | PermissionResultDenypython当时我第一次看到这一段定义的时候,其实有点惊喜:它不是简单地给你一个 bool 开关,而是直接把「放行 / 拒绝」建模成两个 dataclass,留足了空间去承载后面的一堆安全语义。
2.1 PermissionMode:全局的「档位开关」#
先看全局层面。源码里通过 PermissionMode 定义了几种整体权限模式:
PermissionMode = Literal[
"default", # 默认模式
"acceptEdits", # 自动接受某些编辑
"plan", # 只生成计划,不直接执行
"bypassPermissions"# 完全绕过权限(极不建议生产用)
]python你可以把它当成一辆车的「档位」:
default:严格按照规则和权限回调来;acceptEdits:偏向「自动接受编辑类操作」,适合开发者一个人玩;plan:让 Claude 只给出「要做什么」的计划,由人类或其他系统执行;bypassPermissions:实验或本地调试用,生产环境基本不该出现。
在 ClaudeAgentOptions 里,这个东西就是一个普通字段:
options = ClaudeAgentOptions(
permission_mode="default",
)python2.2 PermissionResult:这一次到底放不放?#
回到「谁拍板」的问题上来。权限回调的返回值被建模为两个 dataclass:
@dataclass
class PermissionResultAllow:
behavior: Literal["allow"] = "allow"
updated_input: dict[str, Any] | None = None
updated_permissions: list[PermissionUpdate] | None = None
@dataclass
class PermissionResultDeny:
behavior: Literal["deny"] = "deny"
message: str = ""
interrupt: bool = Falsepython含义分别是:
- Allow:
updated_input:你可以在放行前改写这次调用的参数;updated_permissions:顺便动态更新权限规则(下面单独说);
- Deny:
message:给 Claude 的反馈信息(为什么不让用);interrupt:是否直接中断当前会话。
这个设计有两个很实用的点 :
- 决策结果是强类型的(而不是随手
return {"allow": True}那种),IDE 友好、也方便在测试里断言; - 把「当前这一次调用」和「未来的长期策略」拆开了:这一次可以 deny,但顺带给将来加一条规则,也可以反过来。
2.3 PermissionUpdate:权限规则可以长在「每一次决策」里#
权限系统如果完全静态配置,很快就会遇到两个极端:要么「啥都问你」,要么「一开始就开太大」。PermissionUpdate 就是用来给它加一点「长记性」能力的:
@dataclass
class PermissionUpdate:
type: Literal[
"addRules", "replaceRules", "removeRules",
"setMode", "addDirectories", "removeDirectories",
]
rules: list[PermissionRuleValue] | None = None
behavior: PermissionBehavior | None = None
mode: PermissionMode | None = None
directories: list[str] | None = None
destination: PermissionUpdateDestination | None = None
def to_dict(self) -> dict[str, Any]:
... # 转成 CLI 协议需要的结构python配合 CLI 返回的 permission_suggestions,你的权限回调可以玩出这些花样:
- 「先 ask,人点同意后,自动
addRules记下这条规则」; - 给不同的
destination(userSettings / projectSettings / localSettings / session)施加不同作用域的更新。
2.4 can_use_tool 在 Query 里是怎么走的?#
我们顺着 _internal/query.py 看一下,在 Query._handle_control_request 分支里,针对 "can_use_tool" 这个 subtype,大致逻辑是这样的:
if subtype == "can_use_tool":
permission_request: SDKControlPermissionRequest = request_data
original_input = permission_request["input"]
context = ToolPermissionContext(
signal=None,
suggestions=permission_request.get("permission_suggestions", []) or [],
)
response = await self.can_use_tool(
permission_request["tool_name"],
permission_request["input"],
context,
)
if isinstance(response, PermissionResultAllow):
# behavior = allow,构造 updatedInput / updatedPermissions
elif isinstance(response, PermissionResultDeny):
# behavior = deny,附带 message / interruptpython这里有几个安全工程上挺关键的点:
- 最后一拍板的人在 SDK 这边,不会被 CLI 的内部逻辑「偷偷改决策」;
- CLI 更多是把上下文和
permission_suggestions传给你,真正是否采纳、怎么采纳,是在你的 Python 回调里完成的; - 如果你不提供
can_use_tool,而又暴露了一堆工具出去,本质上就是「完全信任 CLI 自己的权限策略」,开发阶段 OK,生产环境最好别这么干。
2.5 一个更接地气的权限回调示例#
说了这么多,来一个可以直接 copy 走的小例子:允许读文件,但一律拒绝删除类操作:
from claude_agent_sdk import ClaudeAgentOptions
from claude_agent_sdk.types import (
PermissionResultAllow,
PermissionResultDeny,
ToolPermissionContext,
)
async def can_use_tool(tool_name: str, tool_input: dict, ctx: ToolPermissionContext):
# 只允许读取,禁止删除类操作
if tool_name in {"ReadFile", "ListFiles"}:
return PermissionResultAllow()
if tool_name in {"DeleteFile", "DangerousCommand"}:
return PermissionResultDeny(
message="该工具已被安全策略禁用,请联系管理员。",
interrupt=False,
)
# 其他工具暂时全部拒绝
return PermissionResultDeny(
message=f"工具 {tool_name} 未在白名单中。",
interrupt=False,
)
options = ClaudeAgentOptions(
can_use_tool=can_use_tool,
permission_mode="default",
)python在真实环境里,这里就是你接各种「大厂味」东西的地方:
- 审批流(比如某些高危工具需要走工单 / oncall 确认);
- 按人 / 按项目 / 按环境分层的策略(prod 比 dev 严得多);
- 日志与审计系统(把每一次工具调用尝试都记下来)。
三、Hook 机制:在关键路径上插一脚#
如果说「权限系统」更像是配置中心里的一堆规则,那 Hook 就更像是你在代码里插的一个个「观察点 / 拦截点」。
3.1 Hook 输入与输出的完整结构#
在前一篇里我们已经看过 Hook 事件的枚举,这里直接上输入输出类型,结合源码感受一下设计味道:
class PreToolUseHookInput(BaseHookInput):
hook_event_name: Literal["PreToolUse"]
tool_name: str
tool_input: dict[str, Any]
class SyncHookJSONOutput(TypedDict):
continue_: NotRequired[bool] # 是否继续(continue_ → continue)
suppressOutput: NotRequired[bool] # 是否在 transcript 里隐藏输出
stopReason: NotRequired[str]
decision: NotRequired[Literal["block"]]
systemMessage: NotRequired[str]
reason: NotRequired[str]
hookSpecificOutput: NotRequired[HookSpecificOutput]
class PreToolUseHookSpecificOutput(TypedDict):
hookEventName: Literal["PreToolUse"]
permissionDecision: NotRequired[Literal["allow", "deny", "ask"]]
permissionDecisionReason: NotRequired[str]
updatedInput: NotRequired[dict[str, Any]]python几个要点:
- Python 里用的是
continue_/async_这些避开关键字的名字,Query会在发回 CLI 前通过_convert_hook_output_for_cli把它们转换成continue/async; hookSpecificOutput按事件类型细分,比如PreToolUse可以给出permissionDecision和updatedInput;- Hook 回调是 async 的,可以很自然地做网络请求、写日志、拉黑名单等操作。
3.2 Hook 在 Query 里面是怎么「串」起来的?#
从生命周期角度看,Hook 大致经历这么几步:
- 启动时,通过
Query.initialize()把你的HookMatcher配成 CLI 能理解的结构; - CLI 在合适的时机发一条
hook_callback控制请求过来; Query._handle_control_request根据 callback_id 找到你注册的 Python 函数,await 一下;- 把你返回的
HookJSONOutput转换字段名后丢回 CLI。
源码里第一步大概长这样:
for event, matchers in self.hooks.items():
for matcher in matchers:
callback_ids = []
for callback in matcher.get("hooks", []):
callback_id = f"hook_{self.next_callback_id}"
self.hook_callbacks[callback_id] = callback
callback_ids.append(callback_id)
hook_matcher_config = {
"matcher": matcher.get("matcher"),
"hookCallbackIds": callback_ids,
"timeout": matcher.get("timeout"),
}python之后当 CLI 想触发某个 Hook 时,会发来 hook_callback 控制请求,对应的处理是:
elif subtype == "hook_callback":
callback_id = hook_callback_request["callback_id"]
callback = self.hook_callbacks.get(callback_id)
hook_output = await callback(
request_data.get("input"),
request_data.get("tool_use_id"),
{"signal": None},
)
response_data = _convert_hook_output_for_cli(hook_output)python站在 SDK 使用者角度看:
- Hook 函数本质就是一个
async def,输入是强类型的HookInput,输出HookJSONOutput; - CLI 再根据你返回的信息决定要不要继续执行、怎么提示用户;
matcher字符串(例如"Bash"或"Write|MultiEdit|Edit")则让你可以非常精细地「只针对某类工具/操作生效」。
3.3 一个「审计 + 拉闸」型 Hook 示例#
结合前面的权限系统,我们来写一个很常见的场景:
- 把所有工具调用都打到审计日志里;
- 对于高危命令,在真正执行前直接拉闸。
from claude_agent_sdk import ClaudeAgentOptions
from claude_agent_sdk.types import HookMatcher, HookInput, HookContext, HookJSONOutput
async def pre_tool_use_audit(input: HookInput, tool_use_id: str | None, ctx: HookContext) -> HookJSONOutput:
tool_name = input.get("tool_name")
tool_input = input.get("tool_input", {})
# 记录到你的审计系统
log_tool_call(tool_name, tool_input, session_id=input["session_id"])
# 对高危命令直接要求人工确认
if tool_name in {"DeleteFile", "DangerousCommand"}:
return {
"continue_": False,
"stopReason": "危险命令需要人工审批",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "高危命令拦截",
},
}
return {"continue_": True}
options = ClaudeAgentOptions(
hooks={
"PreToolUse": [
HookMatcher(
matcher=None, # 所有工具
hooks=[pre_tool_use_audit],
timeout=5.0,
)
]
},
)python这里 Hook 做的是:
- 记录所有工具调用(审计);
- 在真正调用前对高危命令「二次拦截」,并附带原因。
和前面的 can_use_tool 配合,你可以做到:
- 静态白名单/黑名单:用权限系统表达;
- 动态风控规则:用 Hook 表达;
- 运维 & 审计:Hook 里写日志、打点、接 webhook。
四、Sandbox:把 Bash 关进笼子里#
最后一层是 Sandbox。源码里的注释已经写得很直白:
Filesystem and network restrictions are configured via permission rules (Read/Edit/WebFetch), not via these sandbox settings.
换句话说:
- 真正限制「能不能读/写/访问网络」的是权限规则(Read/Edit/WebFetch);
- Sandbox 更像是一层「执行环境层面」的隔离:把 Bash 放在一个受控空间里跑,即便权限配置有点疏漏,也不至于直接打到宿主机的敏感面上。
4.1 SandboxSettings 关键字段回顾#
class SandboxSettings(TypedDict, total=False):
enabled: bool
autoAllowBashIfSandboxed: bool
excludedCommands: list[str]
allowUnsandboxedCommands: bool
network: SandboxNetworkConfig
ignoreViolations: SandboxIgnoreViolations
enableWeakerNestedSandbox: boolpython其中:
enabled:是否启用 Bash 沙箱(仅在 macOS/Linux 有效);autoAllowBashIfSandboxed:启用沙箱后是否默认放行 Bash(通常为 True);excludedCommands:e.g."git"、"docker"这类你希望在宿主环境里运行的命令;allowUnsandboxedCommands:是否允许通过 dangerouslyDisableSandbox 完全绕过沙箱(安全上不建议轻易打开);network:允许哪些 Unix Socket、本地端口、代理端口等;ignoreViolations:哪些路径/主机的违规可以「睁一只眼闭一只眼」。
4.2 一个生产友好的 Sandbox 配置示例#
from claude_agent_sdk.types import SandboxSettings
sandbox_settings: SandboxSettings = {
"enabled": True,
"autoAllowBashIfSandboxed": True,
"excludedCommands": ["docker"], # 宿主环境里跑
"allowUnsandboxedCommands": False,
"network": {
"allowUnixSockets": ["/var/run/docker.sock"],
"allowLocalBinding": True,
},
"ignoreViolations": {
"file": [],
"network": [],
},
}
options = ClaudeAgentOptions(
sandbox=sandbox_settings,
# 再配合权限规则限制文件/网络访问
)python结合前面的权限系统 + Hook,你可以形成这样一套安全姿态:
- 权限规则:决定「哪些路径/主机/命令允许访问」;
- Sandbox:即便权限规则配置错了,Bash 也跑不出这个沙箱;
- Hook + 审计:所有关键行为都有日志可查,并能动态拉闸。
五、收个尾:如果你要做一个自己的「Claude 平台」#
如果你准备把 Claude Agent SDK 嵌进自己的平台或产品,这一套「权限 + Hook + Sandbox」大致可以这样落地:
-
先设计一套内部的「工具分级与风险模型」:
- 哪些是只读工具;
- 哪些是改代码/写文件;
- 哪些是高危(删库、重启服务、访问内网)。
-
基于这个模型配置 PermissionMode + 权限规则:
- 高危工具默认 deny,只能在审批后临时开放;
- 不同租户/项目有各自的规则集合;
-
在 can_use_tool 里接入你自己的用户/项目上下文:
- 谁在用?在哪个 workspace?什么时间段?
-
用 Hook 做风控 & 审计:
- PreToolUse:高危命令二次确认、打审计日志;
- PostToolUse:记录工具执行结果、埋点监控;
-
为 Bash 工具开启 Sandbox,并定期 review 配置:
- 确保
allowUnsandboxedCommands=False(除非极特殊场景); - 检查
excludedCommands和network白名单没有被滥开。
- 确保
从这几块源码拆下来,其实能看出一件事:Anthropic 是真的把「安全 & 治理」当成一等公民来设计这套 SDK 的:
- 权限系统对齐 TypeScript SDK 的控制协议;
- Hook / 权限 / Sandbox 都有比较认真地做类型建模;
- anyio + Query 的结构也保证了出错时不会静默吞掉,而是能回传到调用方。
如果你已经在把第一篇里的最小示例跑起来了,很推荐你顺手在本地把:
- 一个最简单的
can_use_tool白名单; - 一个
PreToolUse审计 Hook; - 一份保守一点的 Sandbox 配置;
加到自己的项目里先跑一圈。