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 、持久化审计 、单一策略引擎 与 针对拒绝链路的测试。


相关推荐
紧跟先前的步伐5 小时前
Go AI Agent 实战 24|工具系统工程化收尾与阶段验收
人工智能·ai·golang·1024程序员节
xzlAwin6 小时前
Win10 安装OpenClaw 龙虾
ai·openclaw·龙虾
IDIOT___IDIOT11 小时前
DSH 对话回收站(dsh-session-trash)
ai·dsh
玩AI的奶茶11 小时前
MiniMax H3 出 30 秒长片:显存账、拼接法和一次的成本
大数据·人工智能·ai·gpu算力·算力租赁
犀思云11 小时前
从SD-WAN到AI WAN:企业广域网能力演进与架构变化解读
ai·sd-wan·wan·企业广域网
犀思云13 小时前
AI WAN实践:分布式AI训练与推理如何通过智能广域网提升效率?
ai·wan·分布式ai训练·分布式ai推理·智能广域网
Maynor99613 小时前
ChatGPT Pro 额度档位怎么理解:5x / 20x / 50x 使用笔记
ai·chatgpt·教程
吴名氏.14 小时前
Java转AI Agent教程(第一周)让模型先跑起来
人工智能·python·ai·ai agent
FPGA小迷弟14 小时前
FPGA 与无人机:从飞控实时性到智能载荷的硬件加速实践
人工智能·ai·fpga开发·架构·无人机·ai编程
zhangphil15 小时前
Agent决定做什么,Skill定义怎么做,MCP让它能调用外部能力
ai