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

python 复制代码
# 22:25:src/models.py
@dataclass(frozen=True)
class PermissionDenial:
    tool_name: str
    reason: str

优点 :不可变、语义直白------哪个工具 、人类可读原因 。
成熟产品常扩展 (当前未实现):code(机器可读枚举,如 POLICY_BASH_DISABLED)、policy_id / policy_version、requested_by(会话/用户)、timestamp、correlation_id(与某次 tool_call id 对齐)。


3. 两条并行链路:不要混成一种「拒绝」

3.1 清单层:ToolPermissionContext(工具面收缩)

python 复制代码
# 6:20:src/permissions.py
@dataclass(frozen=True)
class ToolPermissionContext:
    deny_names: frozenset[str] = field(default_factory=frozenset)
    deny_prefixes: tuple[str, ...] = ()

    @classmethod
    def from_iterables(cls, deny_names: list[str] | None = None, deny_prefixes: list[str] | None = None) -> 'ToolPermissionContext':
        return cls(
            deny_names=frozenset(name.lower() for name in (deny_names or [])),
            deny_prefixes=tuple(prefix.lower() for prefix in (deny_prefixes or [])),
        )

    def blocks(self, tool_name: str) -> bool:
        lowered = tool_name.lower()
        return lowered in self.deny_names or any(lowered.startswith(prefix) for prefix in self.deny_prefixes)

通过 filter_tools_by_permission_context / get_tools 使用:

python 复制代码
# 56:59:src/tools.py
def filter_tools_by_permission_context(tools: tuple[PortingModule, ...], permission_context: ToolPermissionContext | None = None) -> tuple[PortingModule, ...]:
    if permission_context is None:
        return tools
    return tuple(module for module in tools if not permission_context.blocks(module.name))

CLI 暴露 --deny-tool、--deny-prefix(main.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(路由之后的策略判定)

python 复制代码
# 169:174:src/runtime.py
    def _infer_permission_denials(self, matches: list[RoutedMatch]) -> list[PermissionDenial]:
        denials: list[PermissionDenial] = []
        for match in matches:
            if match.kind == 'tool' and 'bash' in match.name.lower():
                denials.append(PermissionDenial(tool_name=match.name, reason='destructive shell execution remains gated in the Python port'))
        return denials

语义 :在 已经路由到某工具名 之后,根据 工具类危险度 附加 拒绝说明 ,并交给 QueryEnginePort 进入 TurnResult / 流式事件 / 累积列表。

与 3.1 的区别:

维度 ToolPermissionContext _infer_permission_denials
阶段 列清单 / 组 tool pool 之前 路由得到 matches 之后
对象 全体工具条目 本轮命中的工具
产物 过滤后的 PortingModule 元组 PermissionDenial 列表
典型产品映射 「不给模型这项能力」 「模型要了,但策略拒绝」

成熟产品需要 两条都做 ,且 语义命名 上要区分 filtered out vs denied at gate,避免运营把「列表里根本没有」误当成「用户拒绝了调用」。


4. 进入会话与协议:QueryEnginePort 如何把拒绝变成「可观测」

bootstrap_session 把 同一轮 的 matched_tools 与 denials 一并传入:

python 复制代码
# 121:133:src/runtime.py
        denials = tuple(self._infer_permission_denials(matches))
        stream_events = tuple(engine.stream_submit_message(
            prompt,
            matched_commands=tuple(match.name for match in matches if match.kind == 'command'),
            matched_tools=tuple(match.name for match in matches if match.kind == 'tool'),
            denied_tools=denials,
        ))
        turn_result = engine.submit_message(
            prompt,
            matched_commands=tuple(match.name for match in matches if match.kind == 'command'),
            matched_tools=tuple(match.name for match in matches if match.kind == 'tool'),
            denied_tools=denials,
        )

流式侧单独事件类型:

python 复制代码
# 118:119:src/query_engine.py
        if denied_tools:
            yield {'type': 'permission_denial', 'denials': [denial.tool_name for denial in denied_tools]}

成熟产品味 :拒绝 先于 message_delta 流出,前端可以 toast / 红标 / 审计面板 ;当前实现里 denials 列表只序列化了 tool_name ,reason 未进 SSE 形状------生产里建议 denials: [{tool_name, reason, code?}]。

引擎内 跨轮累积(成功路径):

python 复制代码
# 93:94:src/query_engine.py
        self.permission_denials.extend(denied_tools)

render_summary() 打印 Permission denials tracked: {len(self.permission_denials)},便于 一眼看会话级拒绝次数。


5. 当前实现与「成熟产品」的关键差距(执行顺序)

bootstrap_session 中 工具 shim 的执行 与 denials 推断 的顺序如下:

python 复制代码
# 117:121:src/runtime.py
        matches = self.route_prompt(prompt, limit=limit)
        registry = build_execution_registry()
        command_execs = tuple(registry.command(match.name).execute(prompt) for match in matches if match.kind == 'command' and registry.command(match.name))
        tool_execs = tuple(registry.tool(match.name).execute(prompt) for match in matches if match.kind == 'tool' and registry.tool(match.name))
        denials = tuple(self._infer_permission_denials(matches))

也就是说:凡路由到的 tool(含名称含 bash 的)都会先走 registry.tool(...).execute ,然后 才计算 PermissionDenial。在 mirrored shim 下 execute_tool 只返回描述性字符串,没有真实副作用 ,所以不构成安全漏洞;但一旦换成 真 Bash / 真文件写 ,这就是典型 「先执行再拒绝」 的反模式。

成熟产品应遵循:

  1. Policy evaluate (得到 Allow | Deny(reason))
  2. Deny → 只写审计与 PermissionDenial,不调用 executor
  3. Allow → 再进入 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。
  • 输出:Decision:allowed | denied(PermissionDenial) | needs_approval(prompt_to_user)。
  • 禁止 在三个文件里各写一段 if 'bash' in name;应集中注册 规则表或插件。

6.2 拒绝与调用的 ID 对齐

  • 每次模型发起的 tool_call 有 stable id ;PermissionDenial(或审计行)携带 同一 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 、持久化审计 、单一策略引擎 与 针对拒绝链路的测试。


相关推荐
百数平台7 小时前
百数AI文本知识库配置详解:本地文档上传与自定义录入、解析分段策略全说明
低代码·ai
百数平台8 小时前
百数AI对话流(Chatflow)开发指南:会话历史配置、角色面板、开场白预置问题与智能体挂载
低代码·ai
笨蛋©9 小时前
[实战] 扫描图纸转DXF:2026年制造业数字化图纸处理与GD&T识别实操指南
ai·数字化·cad·质量管理·制造业
俊哥V11 小时前
每日 AI 研究简报 · 2026-10-08
人工智能·ai
jason.zeng@150220712 小时前
(六)Prompt 优化
python·ai·langchain·prompt·ai编程·llama
小鹿的周先生12 小时前
第19章-Agent
java·spring·ai
全栈练习生12 小时前
大模型推理全链路
python·ai
欣欣之王来了13 小时前
2024主流国产大模型深度对比:文心一言/通义千问/智谱AI/Qwen等选型指南
人工智能·ai·大模型
笨蛋©14 小时前
2026制造业质量数字化:Infra CONVERT 中国代理技术支持下的检验计划自动化实战
ai·数字化·cad·质量管理·制造业
玩AI的奶茶14 小时前
从零到跑通:在算家云上部署大模型的完整操作手册(实例创建 / 镜像选择 / 远程连接 / 成本控制)
人工智能·ai·gpu算力·token·算力租赁