涉及源码 :
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 / 真文件写 ,这就是典型 「先执行再拒绝」 的反模式。
成熟产品应遵循:
- Policy evaluate (得到
Allow | Deny(reason)) - Deny → 只写审计与
PermissionDenial,不调用 executor - 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 、持久化审计 、单一策略引擎 与 针对拒绝链路的测试。