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

涉及源码src/models.pysrc/permissions.pysrc/tools.pysrc/runtime.pysrc/query_engine.pysrc/main.pysrc/tool_pool.pytests/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_versionrequested_by(会话/用户)、timestampcorrelation_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-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(路由之后的策略判定)

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_toolsdenials 一并传入:

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_namereason 未进 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 shimexecute_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_namearguments 摘要、cwduser_idtrust_tiersession_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 默认安全姿态

  • 高危险工具 默认 Denyneeds_approval ;与 claw-code 对 bash 的 gated 叙事一致,但需 执行闸门 与叙事一致。

6.6 测试策略

  • 单元 :给定 matches + policy,断言 (exec_calls, denials)
  • 集成 :CLI/bootstrapdeny 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持久化审计单一策略引擎针对拒绝链路的测试


相关推荐
海盗12342 分钟前
AI科技周刊:2026年5月中旬大模型竞争白热化
人工智能·科技·ai
Android出海5 分钟前
2026主流AI工具对比:ChatGPT、Gemini、Claude、Grok深度分析与选择
人工智能·ai·chatgpt·claude·grok·ai工具·gemini
周公16 分钟前
记一次在双 RTX 3090 工作站上部署 vLLM 与 Qwen3.6-35B-AWQ 的实战记录
python·ai·llama·vllm·ollama
闲人编程31 分钟前
开源 vs 闭源:构建Agent该如何选择基座模型?
ai·开源·微调·智能体·决策·自进化·决策矩阵
特立独行的猫a1 小时前
轻量级 AI 编码新力量| AtomCode Air 完全指南:用自然语言编程,让创意即时落地
人工智能·ai·agent·使用指南·atomcode·atomcode air
marsh02061 小时前
47 openclaw监控指标设计:关键性能指标(KPI)选择与实现
网络·ai·编程·技术
米饭好好吃.1 小时前
n8n 配置 workflow
ai
土星云SaturnCloud1 小时前
边缘计算赋能工业智能化:重大危险源监测+产线控制+视觉分析一体化解决方案
服务器·人工智能·ai·边缘计算
爱编程的小新☆1 小时前
Langchain4j框架入门
ai·langchain4j
m0_634666731 小时前
Zero 和 Spec Kit:AI Agent 正在把“编程”推向更显式的契约时代
人工智能·ai