# Batch J Worker B:tool availability 三处一致性审查 > 创建时间:2026-05-21 > > 只读审查,不修改代码。 ## 1. 读取过的关键文件 | 文件 | 行数 | 内容 | |------|------|------| | `rust/crates/mnote-web/src/hermes_tools/manifest.rs` | 400+ | Tool 定义:name、description、`status`、`capabilityScope`、`annotations` | | `rust/crates/mnote-web/src/hermes_tools/mod.rs` | 280+ | `ToolCallInput` 结构体 + `ensure_write_authorized()` 守卫 | | `rust/crates/mnote-web/src/routes/hermes_tools.rs` | 3691 | `execute_mnote_tool_call()` — 入口 + 工具分派 | | `rust/crates/mnote-web/src/routes/hermes_client.rs` (L2247-2302) | ~55 | `disabled_mnote_tools()`, `mnote_tools_payload()`, `mnote_tool_entry()` | | `rust/crates/mnote-web/src/ssr/pages/layout.rs` (L50, L6346-6390, L6895-6990) | ~100 | `pageAiTools` 状态、`pageAiNormalizeTools()`、tool 渲染 | ## 2. Manifect / UI / Execute guard 能力判断来源 ### 2.1 Manifect(`hermes_tools/manifest.rs`) ```rust // 每个工具定义包含: { "name": "mnote.doc.fetch", "description": "读取当前页面的 canonical block projection...", "schemaVersion": TOOL_SCHEMA_VERSION, "capabilityScope": ["page.read"], // 需要的权限声明 "status": "available", // 静态 availability "annotations": tool_annotations(true, false, true, false) // readonly, destructive, idempotent, requires_approval } ``` 关键缺陷: - `page_get_tool()` 和 `page_save_tool()` **没有设置 `annotations` 字段** - `available_tool()` 创建的条目也没有 `annotations` - `status` 字段全部硬编码为 `"available"`(无动态 availability) ### 2.2 Tool 列表服务端(`hermes_client.rs:2258` `mnote_tools_payload()`) ``` manifest::manifest() ← 静态 manifest ↓ disabled_mnote_tools(profile) ← 从 profile 配置 YAML 读取 mnote.tools.disabled 列表 ↓ mnote_tool_entry(tool, &disabled) ← 若 tool.name ∈ disabled,设 status=disabled, enabled=false ↓ 返回给前端 ``` **disabled 来源:** `disabled_mnote_tools(profile)`(`hermes_client.rs:2247`) ```rust pub(crate) fn disabled_mnote_tools(profile: &str) -> Vec { let content = fs::read_to_string(profile_config_path(profile)).unwrap_or_default(); yaml_disabled_list(&content, &["mnote", "tools", "disabled"]) } ``` 从 Hermes profile YAML 的 `mnote.tools.disabled` 键下读取禁用列表。 ### 2.3 UI(`layout.rs`) 工具列表渲染链路: ``` fetch('/api/hermes/client/tools?scope=mnote&profile=' + profile) ↓ pageAiNormalizeTools(payload) ↓ 提取: name, description, scope, status, enabled, unavailableReason ↓ renderPageAiControls() ↓ 渲染 tool row: name + scope + status + toggle button toggle 状态: tool.enabled !== false → aria-pressed + is-on CSS ``` UI 对所有工具平等渲染,**不隐藏 disabled 工具**(只显示 switching 开关 + `"当前 Hermes profile 已关闭该 mnote tool"` 提示)。前端不做任何独立的 capability 判断。 ### 2.4 Execute guard(`hermes_tools.rs:219` + `mod.rs` `ensure_write_authorized()`) 守卫有两层: **第一层**(`hermes_tools.rs:219` — `execute_mnote_tool_call()` 入口): ```rust if hermes_client::is_mnote_tool_disabled(&profile, &input.tool_name) { return Err(403 "mnote_tool_disabled") } ``` **同源**:与 listing 端调用同一个 `is_mnote_tool_disabled()` → `disabled_mnote_tools()`。 **第二层**(`mod.rs:186` — `ensure_write_authorized()` 在具体 tool handler 中调用): ```rust pub fn ensure_write_authorized(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> { // 1. idempotencyKey 必须存在 // 2. dryRun 必须显式携带 // 3. aiAccessScope.permissionLevel 不能是只读 // 4. CommandContextBridge.ai_can_write ≠ false // 5. CommandContextBridge.workspace_readonly ≠ true } ``` 这层不涉及 manifest 的 capabilityScope。 **第三层**(`hermes_tools.rs:242` — shared read 检查): ```rust if !dry_run && !is_read_tool(&input.tool_name) && is_shared_read_scope(&input) { return Err(403 "mnote_tool_shared_read_write_forbidden") } ``` 通过 `aiAccessScope.permissionLevel` 判断共享只读。 ## 3. 三处是否同源 ### 3.1 Disabled 列表:✅ 完全同源 | 层 | 判断 | 来源 | |----|------|------| | Manifect | 无动态 disabled(static) | — | | Listing | `disabled_mnote_tools(profile)` | profile YAML `mnote.tools.disabled` | | UI | 接收 `enabled` 字段 | 来自 listing endpoint | | Execute guard | `is_mnote_tool_disabled(profile, name)` | 同一 `disabled_mnote_tools()` | 所有层在"哪些工具被禁用"上使用同一数据源(Hermes profile YAML)。 ### 3.2 capabilityScope 执行:⚠️ 不同源(P1) | 层 | capabilityScope 用法 | 角色 | |----|---------------------|------| | Manifect | 每个工具声明 `capabilityScope: ["page.read"]` 或 `["page.write"]` | **文档性** — 告诉 AI agent 需要什么权限 | | Listing | 转发 `capabilityScope` 到 UI | **展示性** | | UI | 不消费 `capabilityScope` 做判据 | **不展示权限要求** | | Execute guard | **不检查** `input.capability_scope` 与 manifest scope 的匹配 | **未执行** | **关键缺口**:manifect 声明了工具需要的权限范围(`page.read` / `page.write` / `block.write` 等),但 execute guard 没有验证调用方声明的 `capability_scope` 是否包含目标工具所需的 scope。 `ToolCallInput.capability_scope` 虽存在于结构体中,但只做: 1. 传递给具体 tool handler 作为 `RuntimeTargetWire.capabilities`(artifact.rs:187, block.rs:1136, page.rs:279) 2. 记录到 audit 日志(hermes_tools.rs:433) 3. 由 `page_ai_workflow.rs:130` 在调用时手动设置 `capability_scope: Some(vec!["block.write".into(), "page.write".into()])` 无中心守卫验证 `input.capability_scope` 与 manifest 中该 tool 的 `capabilityScope` 的包含关系。 ## 4. P0/P1/P2 分级缺口 ### P0:无 disabled 工具在三层间保持一致,没有安全绕过。 ### P1:capabilityScope 未在 execute guard 强制执行 - **漏洞描述**:Agent 可以声明 `capability_scope: ["page.read"]`,但仍然调用 `mnote.page.save`(写工具)。守卫只检查 `is_shared_read_scope`(看 `aiAccessScope.permissionLevel`)和 `is_read_tool`(看硬编码列表),不检查 manifest 声明的 scope。 - **影响**:低(不构成安全漏洞,因为还有 `aiAccessScope.permissionLevel` + 文件系统权限 + 授权 root 三层兜底),但 manifests 声明的 scope 信息完全浪费。 - **修复建议**:在 `execute_mnote_tool_call()` 中,校验 `input.capability_scope` 是否覆盖 manifest 中该工具的 `capabilityScope`。 ### P2:Manifest 元数据不完整 | 缺陷 | 位置 | 说明 | |------|------|------| | `page_get_tool()` 缺 `annotations` | `manifest.rs` 末尾 | 没有 `readonly`/`destructive` 标记 | | `page_save_tool()` 缺 `annotations` | `manifest.rs` 末尾 | 同上 | | `available_tool()` 创建的工具缺 `annotations` | `manifest.rs` | `mnote.page.update_title`, `mnote.page.update_options`, `mnote.artifact.create_summary`, `mnote.artifact.create_ai_note` | | `status` 字段全部硬编码 | `manifest.rs` | 无动态 availability;manifect 不含 disabled 状态(在 listing 层叠加) | | 前端 UI 不展示 `capabilityScope` | `layout.rs:6346-6380` | `pageAiNormalizeTools` 解析但不消费,渲染时不显示权限要求 | ## 5. 建议的最小测试 ```bash # 已有:disabled tool 被 execute guard 拒绝 cargo test -p mnote-web block_edit_workflow_respects_disabled_markdown_edit_tool # 已有:write guard 检查(aiAccessScope、idempotencyKey) cargo test -p mnote-web ensure_write_authorized # 新增建议:capability_scope 校验测试 # 位置:hermes_tools/mod.rs tests # 内容:构造 ToolCallInput { capability_scope: ["page.read"] } 调用 mnote.page.save,期望被拒绝 ```