# 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<String> {
    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，期望被拒绝
```
