claw-code 源码分析:权限拒绝不是补丁——工具调用链上如何做 `PermissionDenial` 级设计才像成熟产品?

张开发
2026/4/4 22:45:25 15 分钟阅读

分享文章

claw-code 源码分析:权限拒绝不是补丁——工具调用链上如何做 `PermissionDenial` 级设计才像成熟产品?
涉及源码src/models.py、src/permissions.py、src/tools.py、src/runtime.py、src/query_engine.py、src/main.py、src/tool_pool.py、tests/test_porting_workspace.py。1. 为什么说「权限拒绝不是补丁」在智能体产品里常见反模式是工具已经执行完了才发现不该执行于是在日志里打一行「denied」或把异常吞掉。成熟做法是把拒绝deny当作与调用invoke同级的一等概念有类型拒绝不是裸字符串而是结构化记录谁、因何、在链上哪一环。有阶段清单可见性、路由匹配、执行前闸门、执行后审计各阶段语义不同不能混为一谈。有可观测性UI/流式协议/会话持久化都能消费同一套拒绝事件便于合规与排障。与执行顺序一致先判定、后副作用若判定为拒绝默认不得进入真实 I/O。下面先看 claw-code已经做到什么再对照成熟产品还差哪几步。2. 本仓库里的「拒绝」数据模型PermissionDenial# 22:25:src/models.pydataclass(frozenTrue)classPermissionDenial:tool_name:strreason:str优点不可变、语义直白——哪个工具、人类可读原因。成熟产品常扩展当前未实现code机器可读枚举如POLICY_BASH_DISABLED、policy_id/policy_version、requested_by会话/用户、timestamp、correlation_id与某次 tool_call id 对齐。3. 两条并行链路不要混成一种「拒绝」3.1 清单层ToolPermissionContext工具面收缩# 6:20:src/permissions.pydataclass(frozenTrue)classToolPermissionContext:deny_names:frozenset[str]field(default_factoryfrozenset)deny_prefixes:tuple[str,...]()classmethoddeffrom_iterables(cls,deny_names:list[str]|NoneNone,deny_prefixes:list[str]|NoneNone)-ToolPermissionContext:returncls(deny_namesfrozenset(name.lower()fornamein(deny_namesor[])),deny_prefixestuple(prefix.lower()forprefixin(deny_prefixesor[])),)defblocks(self,tool_name:str)-bool:loweredtool_name.lower()returnloweredinself.deny_namesorany(lowered.startswith(prefix)forprefixinself.deny_prefixes)通过filter_tools_by_permission_context/get_tools使用# 56:59:src/tools.pydeffilter_tools_by_permission_context(tools:tuple[PortingModule,...],permission_context:ToolPermissionContext|NoneNone)-tuple[PortingModule,...]:ifpermission_contextisNone:returntoolsreturntuple(moduleformoduleintoolsifnotpermission_context.blocks(module.name))CLI 暴露--deny-tool、--deny-prefixmain.py里构造ToolPermissionContext。测试test_tool_permission_filtering_cli_runs用--deny-prefix mcp断言输出中不再出现MCPTool。语义这是「不把该工具交给模型/用户看」的能力面裁剪capability shrink不是某次调用的PermissionDenial 事件。成熟产品里通常对应system prompt 里的 tools 列表、MCP 注册表、或 UI 里灰掉的按钮。注意这里不产生PermissionDenial记录被拒绝的工具直接不出现在列表里。审计上若要解释「为何模型看不到某工具」需要另记policy 快照或配置变更日志。3.2 运行时层_infer_permission_denials路由之后的策略判定# 169:174:src/runtime.pydef_infer_permission_denials(self,matches:list[RoutedMatch])-list[PermissionDenial]:denials:list[PermissionDenial][]formatchinmatches:ifmatch.kindtoolandbashinmatch.name.lower():denials.append(PermissionDenial(tool_namematch.name,reasondestructive shell execution remains gated in the Python port))returndenials语义在已经路由到某工具名之后根据工具类危险度附加拒绝说明并交给QueryEnginePort进入TurnResult / 流式事件 / 累积列表。与 3.1 的区别维度ToolPermissionContext_infer_permission_denials阶段列清单 / 组 tool pool 之前路由得到matches之后对象全体工具条目本轮命中的工具产物过滤后的PortingModule元组PermissionDenial列表典型产品映射「不给模型这项能力」「模型要了但策略拒绝」成熟产品需要两条都做且语义命名上要区分filtered outvsdenied at gate避免运营把「列表里根本没有」误当成「用户拒绝了调用」。4. 进入会话与协议QueryEnginePort如何把拒绝变成「可观测」bootstrap_session把同一轮的matched_tools与denials一并传入# 121:133:src/runtime.pydenialstuple(self._infer_permission_denials(matches))stream_eventstuple(engine.stream_submit_message(prompt,matched_commandstuple(match.nameformatchinmatchesifmatch.kindcommand),matched_toolstuple(match.nameformatchinmatchesifmatch.kindtool),denied_toolsdenials,))turn_resultengine.submit_message(prompt,matched_commandstuple(match.nameformatchinmatchesifmatch.kindcommand),matched_toolstuple(match.nameformatchinmatchesifmatch.kindtool),denied_toolsdenials,)流式侧单独事件类型# 118:119:src/query_engine.pyifdenied_tools:yield{type:permission_denial,denials:[denial.tool_namefordenialindenied_tools]}成熟产品味拒绝先于message_delta流出前端可以toast / 红标 / 审计面板当前实现里denials列表只序列化了tool_namereason未进 SSE 形状——生产里建议denials: [{tool_name, reason, code?}]。引擎内跨轮累积成功路径# 93:94:src/query_engine.pyself.permission_denials.extend(denied_tools)render_summary()打印Permission denials tracked: {len(self.permission_denials)}便于一眼看会话级拒绝次数。5. 当前实现与「成熟产品」的关键差距执行顺序bootstrap_session中工具 shim 的执行与denials 推断的顺序如下# 117:121:src/runtime.pymatchesself.route_prompt(prompt,limitlimit)registrybuild_execution_registry()command_execstuple(registry.command(match.name).execute(prompt)formatchinmatchesifmatch.kindcommandandregistry.command(match.name))tool_execstuple(registry.tool(match.name).execute(prompt)formatchinmatchesifmatch.kindtoolandregistry.tool(match.name))denialstuple(self._infer_permission_denials(matches))也就是说凡路由到的 tool含名称含 bash 的都会先走registry.tool(...).execute然后才计算PermissionDenial。在mirrored shim下execute_tool只返回描述性字符串没有真实副作用所以不构成安全漏洞但一旦换成真 Bash / 真文件写这就是典型「先执行再拒绝」的反模式。成熟产品应遵循Policy evaluate得到Allow | Deny(reason)Deny→ 只写审计与PermissionDenial不调用executorAllow→ 再进入quota / sandbox / 用户二次确认等下一闸门claw-code 的移植层已经把PermissionDenial类型与协议位摆好下一步硬化应是把tool_execs的生成改为「仅对 allow 列表执行」或与_infer_permission_denials合并为单一policy engine输出(allowed, denied)两组。6. 成熟产品级设计清单可对照实现6.1 策略引擎单一事实源Single policy evaluation输入tool_name、arguments摘要、cwd、user_id、trust_tier、session_flags。输出Decisionallowed|denied(PermissionDenial)|needs_approval(prompt_to_user)。禁止在三个文件里各写一段if bash in name应集中注册规则表或插件。6.2 拒绝与调用的 ID 对齐每次模型发起的 tool_call 有stable idPermissionDenial或审计行携带同一 id便于在日志里 join「请求—拒绝—用户重试」。6.3 分层审计配置层ToolPermissionContext类变更 → 记谁改了 deny_prefix。会话层每轮TurnResult.permission_denials 流式事件。持久化层当前StoredSession未存拒绝列表见result/03.md产品应扩展 schema 或独立append-only audit log。6.4 用户可理解的原因与补救reason给用户看code给自动化看必要时返回「如何开启权限」的 doc 链接仍属 UX不是补丁字符串。6.5 默认安全姿态高危险工具默认 Deny或needs_approval与 claw-code 对 bash 的gated叙事一致但需执行闸门与叙事一致。6.6 测试策略单元给定matches policy断言(exec_calls, denials)。集成CLI/bootstrap在deny bash场景下不得出现成功执行消息真 I/O 接入后。回归test_tool_permission_filtering_cli_runs覆盖清单过滤需补充PermissionDenial 路径的 golden 输出测试。7. 小结claw-code 已具备的「产品骨架」与下一步已具备PermissionDenial结构化类型清单过滤ToolPermissionContext与路由后策略_infer_permission_denials分层TurnResult 流式permission_denial 会话内累积的可观测位。要像成熟产品把拒绝从「叙事补丁」变成「执行闸门」——deny 的工具不得进入真实 executor补齐reason/code/id、持久化审计、单一策略引擎与针对拒绝链路的测试。

更多文章