OpenHuman 会话工具边界策略(agent_policy):基于渠道权限天花板的确定性工具分级与系统提示注入
【免费下载链接】openhumanOpenHuman is an open source personal AI for Mac, Windows and Linux — local-first memory, agent orchestration, and deep research.项目地址: https://gitcode.com/GitHub_Trending/op/openhuman
OpenHuman 是一个面向 macOS、Windows 与 Linux 的开源个人 AI(本地优先记忆、Agent 编排与深度研究)。在单 Agent 会话中,模型能看到的工具集合与其运行时实际能调用的工具必须保持一致,且都要被"渠道(channel)配置的权限天花板"约束。src/openhuman/tools/agent_policy/模块正是负责这项工作的纯逻辑域:它为每个 Agent 会话生成一份确定、不可变的ToolPolicySession快照(每个工具的允许/拒绝/隐藏决策、允许/阻断/隐藏工具名集合、粗粒度任务风险等级),并把活动边界渲染成系统提示中的一小节。读完本文,你将掌握 OpenHuman 中"渠道权限 → 每工具决策 → 风险等级 → 提示注入"的完整链路,以及空配置回退、宽松解析、可见性/权限双轴分类等关键设计。
模块定位:一份纯逻辑、无副作用的安全快照
agent_policy是crate::openhuman::tools下的独立子模块,其唯一职责是把输入映射为输出,不涉及持久化、RPC 或事件。模块文档明确写道:"This domain is pure logic — no persistence, no RPC, no events."
一次build_session调用接收六个输入:
- 活跃的
agent_id; - 发起会话的
channel(如web、cli、telegram); entrypoint(如chat、agent);- 配置好的
channel -> permission字符串映射channel_permissions: &HashMap<String, String>; - 可用工具注册表
tools: &[Box<dyn Tool>]; - 可选的显式可见工具名集合
visible_tool_names: &HashSet<String>。
输出是一份确定性、不可变的ToolPolicySession:同一输入必然产生同一输出,快照没有变更(mutation)API,从生成到会话结束始终保持一致。这样设计的好处是:会话中途无论提示被拼接多少次,模型看到的是同一份边界,不会出现"分类结果漂移"。
模块由四个文件构成(目录):
| 文件 | 角色 |
|---|---|
| mod.rs | 仅导出:模块文档 +mod声明 +pub use重导出引擎、提示渲染器与类型 |
| types.rs | 无 Serde 的纯领域类型:TaskRiskLevel、TaskProfile、ToolPolicyAction、ToolPolicyDecision、ToolCapability、ToolPolicySession(含查询辅助方法),并持有NO_TOOLS_ALLOWED_SENTINEL哨兵常量 |
| engine.rs | ToolPolicyEngine::build_session分类逻辑;私有辅助函数permission_for_channel/parse_permission_level;内联#[cfg(test)]测试套件 |
| prompt.rs | render_tool_policy_boundary+TOOL_POLICY_BOUNDARY_HEADING常量;UTF-8 安全的truncate_utf8;内联#[cfg(test)]测试套件 |
核心职责拆解
README 将模块职责归纳为六条,逐一对应到代码中的具体实现:
- 解析渠道权限天花板:从
channel -> permission字符串映射中解析出某渠道的PermissionLevel(permission_for_channel),并处理各类回退。 - 逐工具分类:把注册表中的每个工具对照天花板与可选可见性集合,产出
ToolPolicyAction(Allow/RequireApproval/Deny/HideFromPrompt)。 - 构建不可变快照:生成附加到 Agent 会话上的
ToolPolicySession(profile、capabilities、允许/阻断/隐藏工具名集合、决策映射)。 - 推导任务风险等级:由最高允许权限推导粗粒度
TaskRiskLevel(Low/Medium/High/Critical)。 - 渲染系统提示边界:输出有界的一节
## Tool Policy Boundary,列出活跃的 agent/channel/entrypoint、允许权限、风险、允许工具与受限数量摘要。 - 运行时 fail-closed:对未知或未列出的工具名,默认决策为
Deny,保证"查不到就是拒绝"。
权限模型:PermissionLevel天花板与排序
模块依赖crate::openhuman::tools::PermissionLevel与Tooltrait(name()、permission_level()),这是它与 openhuman/core 之间唯一的耦合点(见 tools/mod.rs 的重导出)。权限层级通过PermissionLevel的 Ord 排序实现天花板比较,这也是整个分类的核心。
权限到风险等级的映射在 types.rs 中明确定义:
| 允许权限(PermissionLevel) | 风险等级(TaskRiskLevel) | Display 输出 |
|---|---|---|
None/ReadOnly | Low | low |
Write | Medium | medium |
Execute | High | high |
Dangerous | Critical | critical |
TaskRiskLevel是Copy + Eq枚举,其Display实现把等级渲染为小写字符串,供提示文本直接使用。
分类算法:两条相互独立的轴
ToolPolicyEngine::build_session(engine.rs)对每个工具计算两个布尔量,然后三选一:
explicitly_hidden = !visible_tool_names.is_empty() && !visible_tool_names.contains(&name):可见性轴。只要显式可见集合非空且工具不在其中,该工具就被判定为HideFromPrompt。exceeds_permission = required_permission > allowed_permission:权限轴。工具所需权限高于渠道天花板即Deny。
分类优先级为:先看可见性(HideFromPrompt),再看权限(Deny),否则 Allow。也就是说:
if explicitly_hidden => HideFromPrompt(入 hidden_tool_names) else if exceeds_permission => Deny(入 blocked_tool_names) else => Allow(入 allowed_tool_names)注意两条轴完全独立:一个工具可以因为"超过权限天花板"被Deny(进blocked_tool_names),也可以因为"不在显式可见集合中"被HideFromPrompt(进hidden_tool_names),两者可能同时发生但归类互不影响。README 特别指出"Hidden takes precedence over deny in the classification order",与上述代码顺序一致。
ToolPolicyAction::RequireApproval在 match 中已被处理(路由到blocked_tool_names),但当前build_session永远不会产生该动作——这是为未来"审批流"预留的扩展位。
每个工具都会生成一条ToolPolicyDecision与一条ToolCapability存入快照,并通过log::trace!(target: "openhuman::tools::agent_policy", ...)输出[tool-policy] classified tool ...级别的诊断日志,便于按日志目标过滤排查(RUST_LOG 可指定openhuman::tools::agent_policy)。
渠道权限解析的宽松规则
私有函数parse_permission_level(engine.rs)采用宽松解析:trim后转小写、去掉-/_,然后匹配别名:
| 规范化 token | 结果 PermissionLevel |
|---|---|
none | None |
readonly/read | ReadOnly |
write | Write |
execute/exec | Execute |
dangerous/danger | Dangerous |
| 其他任何 token | None(回退 ReadOnly) |
因此配置里写read_only、read-only、ReadOnly、READ都能正确归一化为ReadOnly;写danger等价于dangerous;完全无法识别的 token 不会报错,而是回退到ReadOnly——默认偏保守。
两个关键回退语义(Legacy 逃生门 vs ReadOnly 兜底)
permission_for_channel(engine.rs)实现了最容易踩坑的语义差异:
- 空映射 = 完全放行(legacy 逃生门):如果
channel_permissions映射为空,直接返回PermissionLevel::Dangerous,即不施加任何限制,保留升级前的行为。这保证了老安装(以及未 seed 映射的单元测试夹具)不会在升级瞬间被锁死。 - 只要映射非空,缺失渠道一律 ReadOnly:一旦存在任意渠道策略,那么映射中缺失的渠道、或值无法解析的渠道,都回退到
PermissionLevel::ReadOnly,而不是无限制。
真正的加固落在配置层:AgentConfig::migrate_channel_permissions_if_legacy在启动时对 legacy 安装执行迁移,用安全的逐渠道默认值 seed 映射,使天花板在升级后的第一次启动就生效。这正是"引擎保持纯逻辑、加固下沉到配置层"的分层设计。
快照查询 API 与 fail-closed 兜底
ToolPolicySession在 types.rs 提供五个查询辅助方法:
is_allowed(name) -> bool:判断工具名是否在allowed_tool_names中;has_restrictions() -> bool:blocked_tool_names或hidden_tool_names任一非空即为 true;restricted_tool_count() -> usize:阻断数 + 隐藏数之和;visible_tool_names_for_prompt() -> HashSet<String>:见下文哨兵机制;decision_for(name) -> ToolPolicyDecision:查决策映射,查不到时默认返回Deny(required_permission: None,allowed_permission取 profile 值)——这就是 fail-closed 兜底:未知工具名在运行时永远得不到放行。
ToolPolicyDecision::is_denied()的定义值得注意:任何非Allow的动作(包括RequireApproval、Deny、HideFromPrompt)都视为"拒绝"。也就是说,被隐藏的工具在运行时同样不可调用。
空-但-受限的哨兵机制
当存在限制但没有任何工具被允许时,visible_tool_names_for_prompt()会插入哨兵常量:
const NO_TOOLS_ALLOWED_SENTINEL: &str = "__openhuman_no_policy_allowed_tools__";这样提示渲染能区分"空但受限"({"__openhuman_no_policy_allowed_tools__"})与"完全不受限"(空集合),避免模型把"受限的空表面"误解成"不受限"。
系统提示渲染:## Tool Policy Boundary
render_tool_policy_boundary(session, max_bytes) -> Option<String>(prompt.rs)在会话无限制(!has_restrictions())时返回None(不注入任何提示);否则渲染一段紧凑的系统提示小节:
## Tool Policy Boundary - Agent: orchestrator - Channel: web - Entry point: chat - Allowed permission: read_only - Risk: low - Allowed tools: read_notes - Restricted tools: 1 omitted by policy渲染内容的要点:
TOOL_POLICY_BOUNDARY_HEADING = "## Tool Policy Boundary"是固定的标题常量;- 逐行列出
Agent、Channel、Entry point、Allowed permission(PermissionLevel的 Display)、Risk(TaskRiskLevel的 Display); - 允许工具非空时才输出
Allowed tools:行(逗号连接,BTreeSet保证有序); - 受限数量 > 0 时输出
Restricted tools: N omitted by policy摘要行,不逐个列出被隐藏/阻断的工具名——既告诉模型边界存在,又避免把敏感工具名泄露进上下文; - 输出经过
truncate_utf8保证不超过max_bytes。
truncate_utf8(prompt.rs)保证截断发生在字符边界上,不会把多字节 UTF-8 字符切断;当空间足够时追加\n[...truncated]标记(仅当max_bytes >= marker.len()时),max_bytes == 0时直接清空。
实际接入点在会话轮次提示构建处:render_tool_policy_boundary(&self.tool_policy_session, 2048)(turn/context.rs),即每轮对话的系统提示固定预留最多 2048 字节给边界小节。
会话集成:builder / runtime / turn / types 四处接线
README 列出模块在会话生命周期中的四个使用方,源码均可以证实:
- builder/setters.rs:在构建会话时调用
ToolPolicyEngine::build_session(...)生成工具策略会话与渠道策略会话(注意 setters 里出现了两次调用,对应工具级与会话级两种策略维度); - builder/mod.rs:
tool_policy: &ToolPolicySession作为构建参数传递; - runtime.rs 与 runtime_impl_01_part_01.rs:运行时重建
ToolPolicySession,保证提示中缺失的工具是"真被策略拒绝",而不只是"没出现在提示里"; - types.rs:
ToolPolicySession作为pub(super)字段挂在会话类型上,随会话生命周期存活; - turn/context.rs:每轮把边界小节注入提示。
从源码结构看,这种"构建时生成快照 → 运行时查询决策 → 每轮注入提示"的三角结构,确保了提示可见性与运行时执行严格对齐:模型只能看到被允许的工具,运行时查询同样以快照为准,双端共用同一份不可变数据。
测试验证:从单元测试看行为契约
模块附带两套内联测试,直接固化了上文全部行为:
engine_tests.rs 覆盖四类关键行为:
- 未知渠道回退 ReadOnly:
web=write映射下查询unknown-channel,断言allowed_permission == ReadOnly、read_notes允许、write_notes不允许——验证"有配置后缺失渠道不再放行"。 - 空映射保留 legacy 全量表面:空映射下
allowed_permission == Dangerous,三个工具全部允许且!has_restrictions()——验证逃生门语义。 - 超过天花板即过滤:
web=write下run_script(Execute)被拒绝——验证权限轴。 - 显式可见集合收窄允许面:
cli=execute+visible={"run_script"}时,read_notes/write_notes进入hidden_tool_names而非blocked_tool_names,blocked_tool_names为空——验证可见性轴独立于权限轴。 - fail-closed 默认拒绝:
decision_for("missing_tool").is_denied() == true,未知工具名默认 Deny。
prompt_tests.rs 覆盖提示渲染契约:
- 受限会话渲染出
## Tool Policy Boundary、Agent: orchestrator、Allowed tools: read_notes、Restricted tools: 1 omitted by policy,且不包含被拒绝工具名write_notes; - 80 个长工具名的会话在
max_bytes=192下渲染结果len <= 192且位于字符边界——验证 UTF-8 安全截断; - 空工具表与无限制会话均返回
None——验证"无限制不注入提示"。
实战注意事项与设计启示
结合 README 的 gotchas 与源码,以下是接入或排查时最值得记住的几点:
- 空映射 ≠ 安全:刚升级但还没 seed 渠道策略的实例,
Dangerous意味着工具表面完全放行。务必依赖migrate_channel_permissions_if_legacy在首次启动完成 seed,或在部署时预置映射。 - 写配置时名字可以很随意:
read_only、read-only、READ都解析为ReadOnly;danger==dangerous;无法识别的值回退ReadOnly,不会硬失败。 - 两条轴别混淆:
blocked_tool_names(超权限)与hidden_tool_names(不可见)加起来才是restricted_tool_count();is_denied()把隐藏也算作拒绝,运行时不可调用。 - 提示中的边界是有字节上限的:
render_tool_policy_boundary的max_bytes参数(会话接入时传 2048)由truncate_utf8强制保证,工具名列表过长时会安全截断。 - 快照不可变、决策确定性:调试时看到的分类结果就是模型与运行时共用的那份数据;要改变策略只能重建会话快照,不存在运行时篡改入口。
agent_policy的克制值得借鉴:把"策略分类"做成纯函数式、确定性、不可变的快照域,把迁移、seed 等副作用隔离在配置层,把运行时默认值设为Deny(fail-closed),再用 UTF-8 安全的提示渲染控制上下文开销——四个文件就支撑起"提示可见性 = 运行时执行边界 = 渠道权限天花板"的完整闭环。
【免费下载链接】openhumanOpenHuman is an open source personal AI for Mac, Windows and Linux — local-first memory, agent orchestration, and deep research.项目地址: https://gitcode.com/GitHub_Trending/op/openhuman
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考