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


相关推荐
FIT2CLOUD飞致云1 小时前
支持Hermes Agent与MongoDB管理,1Panel v2.1.9版本发布
ai·开源·1panel
Agent产品评测局2 小时前
临床前同源性反应种属筛选:利用AI Agent加速筛选的实操方案 —— 2026企业级智能体选型与技术落地指南
人工智能·ai·chatgpt
Hanniel2 小时前
Claude CLI免费安装和配置
ai·claude
JTaoX2 小时前
WriteUp-Reverse(Hello,CTF、re1、simple-unpack)
ida·web·writeup·reverse·upx
AI原来如此3 小时前
AI 编程助手常见问题 10 问 10 答
人工智能·ai·大模型·编程
曲幽3 小时前
FastAPI + Pydantic 模型终极实战手册:从能跑就行到固若金汤,这些技巧你一定用得上
python·fastapi·web·model·field·pydantic·validator·basemodel
哥不是小萝莉3 小时前
OpenClaw vs Hermes Agent
ai
基因改造者5 小时前
Hermes Agent 配置指南
人工智能·ai·hermes agent
Java小白笔记5 小时前
OpenClaw 实战方法论
java·开发语言·人工智能·ai·全文检索·ai编程·ai写作
遇见火星6 小时前
OpenAI Codex 使用教程
ai·openai·codex