4.7 IScopeTool 工作区感知

`IScopeTool` 是 RAF 工作区管理的核心接口,使工具能够感知当前工作区边界,在工具内部执行越界检测。它配合 `WorkspaceContextProvider` 实现全自动的 scope 注入和审批策略执行。

IScopeTool trait

/// 可感知工作区范围的工具接口。
///
/// 实现此 trait 的工具由 `WorkspaceContextProvider` 在 `add_tool()` 时
/// 自动注入 `WorkspaceScope`,无需工具构造函数传参。
pub trait IScopeTool: ITool {
    /// 使用指定工作区范围创建工具的新实例。
    ///
    /// 新实例从 `scope.root` 获取工作目录,从 `scope.policy` 获取越界策略。
    fn create_scoped(&self, scope: Arc<WorkspaceScope>) -> Arc<dyn ITool>;
}

**关键设计:**

- `IScopeTool` 是 `ITool` 的超 trait(`IScopeTool: ITool`) - `create_scoped()` 接收 `WorkspaceScope`,返回注入 scope 后的新实例 - 工具不需要在构造时知道 scope——完全由 `WorkspaceContextProvider` 注入

WorkspaceScope 与 ScopePolicy

/// 工作区跨范围访问策略
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScopePolicy {
    /// 开发模式——不作任何限制
    AllowAll,
    /// 生产模式——跨范围操作需人机协同审批
    ApproveOutside,
    /// 受限模式——禁止任何跨范围访问
    DenyOutside,
}

/// 工作区范围定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceScope {
    /// 规范化的根路径
    pub root: PathBuf,
    /// 可读名称,注入 system prompt
    pub name: String,
    /// 越界处理策略
    pub policy: ScopePolicy,
    /// 扩展属性——路径白名单、命令白名单、环境变量等
    pub properties: HashMap<String, serde_json::Value>,
}

**三种策略行为:**

| 策略 | 工作区内操作 | 工作区外操作 | 典型场景 | |------|------------|------------|----------| | `AllowAll` | 正常执行 | 正常执行(scope 标签标记为 `outside_workspace`) | 本地开发 | | `ApproveOutside` | 正常执行 | 触发审批事件(`ToolApprovalRequest`) | 协作审查 | | `DenyOutside` | 正常执行 | 工具内部直接拒绝(`"Access denied"`) | 受限沙箱 |

工具内部的 DenyOutside 检查

每个内置文件系统工具在 `call()` 方法中都包含 scope 检查逻辑:

// 标准模式(以 ReadFile 为例)
if let Some(ref scope) = self.scope {
    if scope.policy == ScopePolicy::DenyOutside
        && matches!(scope_status, ScopeStatus::OutsideScope)
    {
        return Ok(ToolResult::error(
            "Access denied: path is outside workspace boundary",
        ));
    }
}

**检查时机**:在路径解析(`resolve_safe`)之后、业务逻辑之前。这确保即使路径通过了目录穿越防护,scope 越界也能被拦截。

Scope 标签响应

每个工具的 JSON 响应中都包含 `"scope"` 字段,通过 `path_guard` 模块的 `ScopeStatus` 确定:

impl ScopeStatus {
    pub fn to_label(&self) -> &str {
        match self {
            ScopeStatus::InScope => "workspace",
            ScopeStatus::OutsideScope => "outside_workspace",
            ScopeStatus::NotApplicable => "none",
        }
    }
}

这个标签让 LLM 了解每次操作的工作区上下文,帮助模型判断是否需要调整路径或请求审批。

WorkspaceContextProvider 的注入机制

`WorkspaceContextProvider` 在 `add_tool()` 时自动处理 scope 注入和审批包裹:

pub fn add_tool(mut self, tool: impl ITool + 'static) -> Self {
    let mut tool: Arc<dyn ITool> = Arc::new(tool);

    // Step 1: scope 注入(检测 IScopeTool 实现)
    if let Some(scoped) = try_inject_scope(&tool, Arc::clone(&self.scope)) {
        tool = scoped;
    }

    // Step 2: 审批包裹(按策略)
    if self.scope.policy == ScopePolicy::ApproveOutside {
        tool = Arc::new(ApprovalRequiredTool::new(tool));
    }

    self.tools.push(tool);
    self
}

try_inject_scope 的下转型机制

`try_inject_scope()` 使用 `AsAny` 超 trait 进行运行时下转型:

fn try_inject_scope(tool: &Arc<dyn ITool>, scope: Arc<WorkspaceScope>) -> Option<Arc<dyn ITool>> {
    let any = tool.as_any();

    if any.downcast_ref::<ReadFile>().is_some() {
        let dummy = ReadFile { scope: None };
        return Some(dummy.create_scoped(scope));
    }
    if any.downcast_ref::<WriteFile>().is_some() {
        let dummy = WriteFile { scope: None };
        return Some(dummy.create_scoped(scope));
    }
    // ... 所有 10 个文件系统工具 + RunCommand ...
    None
}

**为什么需要"已知类型列表"?**

Rust trait object 的 `downcast_ref` 需要编译期知道目标类型。`try_inject_scope` 无法直接检测 `dyn IScopeTool`(因为 trait object 不支持对超 trait 的 downcast),所以使用穷举已知工具类型的方式。对于自定义工具,目前需要手动注入 scope 或在 `WorkspaceContextProvider` 中添加新的下转型分支。

完整集成流程

sequenceDiagram
    participant User as 用户代码
    participant WCP as WorkspaceContextProvider
    participant Tool as 工具 (ReadFile)
    participant PG as path_guard

    User->>WCP: add_tool(ReadFile { scope: None })
    Note over WCP: Step 1: 构造函数<br/>let tool = Arc::new(ReadFile { scope: None })

    WCP->>WCP: try_inject_scope(tool, scope)
    Note over WCP: tool.as_any().downcast_ref::<ReadFile>()<br/>→ Some → create_scoped()

    WCP->>Tool: create_scoped(scope)
    Tool-->>WCP: Arc::new(ReadFile { scope: Some(scope) })

    WCP->>WCP: scope.policy == ApproveOutside?<br/>→ 包裹 ApprovalRequiredTool

    Note over WCP: on_invoking() 时注入指令 + 工具列表

    Note over Tool: Agent 调用 ReadFile

    Tool->>PG: resolve_safe(base_dir, path, scope_root)
    PG-->>Tool: (resolved_path, scope_status)

    Tool->>Tool: scope.policy == DenyOutside<br/>&& scope_status == OutsideScope?
    alt 越界 + DenyOutside
        Tool-->>User: ToolResult::error("Access denied...")
    else 允许
        Tool->>Tool: 执行业务逻辑
        Tool-->>User: ToolResult::success({..., "scope": scope_status.to_label()})
    end

自定义工具实现 IScopeTool

如果你的工具需要工作区感知:

use std::sync::Arc;
use rust_agent_core::{IScopeTool, ITool, WorkspaceScope, ScopePolicy, ToolResult};
use rust_agent_macros::tool;

#[tool(description = "Reads a configuration file and returns parsed contents")]
pub struct MyConfigTool {
    pub scope: Option<Arc<WorkspaceScope>>,
}

impl IScopeTool for MyConfigTool {
    fn create_scoped(&self, scope: Arc<WorkspaceScope>) -> Arc<dyn ITool> {
        Arc::new(MyConfigTool { scope: Some(scope) })
    }
}

impl MyConfigTool {
    async fn call(&self, arguments: serde_json::Value) -> Result<ToolResult> {
        // 1. 解析 base_dir
        let base_dir = self.scope.as_ref()
            .map(|s| s.root.clone())
            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());

        // 2. 路径解析 + scope 检测
        let (resolved, scope_status) = path_guard::resolve_safe(&base_dir, &path, scope_root)?;

        // 3. DenyOutside 检查
        if let Some(ref scope) = self.scope {
            if scope.policy == ScopePolicy::DenyOutside
                && matches!(scope_status, ScopeStatus::OutsideScope)
            {
                return Ok(ToolResult::error("Access denied: path is outside workspace boundary"));
            }
        }

        // 4. 业务逻辑
        // ...

        // 5. 返回结果(含 scope 标签)
        Ok(ToolResult::success(serde_json::json!({
            "config": parsed,
            "scope": scope_status.to_label(),
        })))
    }
}

关键要点

1. **`IScopeTool` 靠 `AsAny` 下转型实现注入**——`WorkspaceContextProvider` 通过 `downcast_ref` 检测具体工具类型 2. **`DenyOutside` 在工具内部拦截**——最快失败,不需要框架层额外检查 3. **`ApproveOutside` 在框架层封装**——通过 `ApprovalRequiredTool` 包裹,由 `FunctionInvokingChatClient` 发出审批事件 4. **`scope` 标签是 LLM 的元信息**——帮助模型理解每次操作的边界上下文 5. **自定义工具参考内置工具模式**——`scope: Option<Arc<WorkspaceScope>>` + `IScopeTool` + scope check 三步