让 AI Agent 只能提议、不能擅自执行:Rust 中 prepare-confirm-execute 权限模型的实现

0 阅读7分钟

AI Agent 最有价值的能力,是能够调用工具完成实际任务。

但这也是最危险的部分。

当模型输出:

{
  "name": "create_task",
  "arguments": {
    "title": "Review chapter 3"
  }
}

这并不代表用户已经授权创建任务。它只能说明:模型“提议”执行这个操作。

如果程序拿到 tool call 后直接调用 Workspace 写入逻辑,模型输出就等价于用户授权。对于创建任务、写入文件、执行代码甚至删除数据来说,这显然是不安全的。

本文介绍一种更稳妥的执行模型:

模型提出工具调用
        ↓
prepare:解析、校验、生成预览
        ↓
confirmation:等待用户授权
        ↓
execute:真正执行副作用

核心原则只有一句话:

Agent 可以提出行动,但不能替用户做决定。


一、权限不是模型提示,而是宿主侧策略

StudyPulse 首先为工具定义权限等级:

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PermissionLevel {
    Read,
    Write,
    Destructive,
    Execute,
}

这些权限分别代表:

  • Read:读取资料、搜索 Workspace、读取任务
  • Write:创建任务、写入 Agent memory、保存 artifact
  • Destructive:删除或不可逆操作
  • Execute:执行本机代码

工具定义同时携带权限:

pub struct ToolDefinition {
    pub name: String,
    pub description: String,
    pub parameters: Value,
    pub permission: PermissionLevel,
}

例如:

definition::<CreateTaskArgs>(
    "create_task",
    "Create a StudyPulse homework or reading task.",
    PermissionLevel::Write,
)

这里的权限字段可以发送给模型,帮助模型理解工具契约,但它本身不能作为安全边界。

真正的安全判断必须发生在 Agent runtime 中:

if prepared.permission != PermissionLevel::Read {
    // 请求用户确认
}

也就是说,当前实现中只有 Read 工具可以直接执行,WriteDestructiveExecute 都必须经过确认。

对应实现:PermissionLevel 与 PreparedTool (line 23)


二、prepare 阶段只负责“准备”,不能产生副作用

工具调用进入注册表后,不会直接执行,而是先进入 prepare()

pub fn prepare(
    &self,
    call_id: impl Into<String>,
    name: &str,
    arguments: Value,
) -> Result<PreparedTool, ToolError>

prepare() 接收的只有:

  • tool call ID
  • 工具名称
  • 模型传入的 JSON 参数

它甚至没有接收 Workspace 参数,因此从接口设计上就不应该修改用户数据。

create_task 为例,prepare 阶段需要完成:

  1. 解析 JSON 参数。
  2. 校验标题不能为空。
  3. 校验重要性范围。
  4. 构造内部 invocation。
  5. 生成给用户看的 preview。

简化后大致如下:

let args = parse::<CreateTaskArgs>(name, arguments)?;

if args.title.trim().is_empty() {
    return Err(ToolError::InvalidArguments {
        tool: name.into(),
        detail: "title must not be empty".into(),
    });
}

if args.importance.is_some_and(|value| !(1..=5).contains(&value)) {
    return Err(ToolError::InvalidArguments {
        tool: name.into(),
        detail: "importance must be between 1 and 5".into(),
    });
}

Ok(PreparedTool {
    call_id,
    name: name.into(),
    permission: PermissionLevel::Write,
    preview: format!("Create task “{}”", args.title.trim()),
    invocation: Invocation::CreateTask(args),
})

PreparedTool 保存的不是原始 JSON,而是已经解析过的内部调用:

pub struct PreparedTool {
    pub call_id: String,
    pub name: String,
    pub permission: PermissionLevel,
    pub preview: String,
    invocation: Invocation,
}

其中 invocation 是私有字段,只有工具注册表内部可以构造和执行。

这带来两个好处:

  • 无效参数不会进入执行阶段。
  • 执行阶段不需要再次依赖未经校验的原始 JSON。

对应实现:prepare() 参数预检 (line 502)


三、Agent 先发事件,再暂停等待用户

prepare 成功后,Agent 不会立刻调用工具,而是先发出一个 ToolRequested 事件:

self.emit(
    &control,
    AgentEventKind::ToolRequested,
    EventFields {
        tool_call_id: Some(prepared.call_id.clone()),
        tool_name: Some(prepared.name.clone()),
        permission: Some(prepared.permission),
        preview: Some(prepared.preview.clone()),
        payload_json: Some(call.arguments.to_string()),
        ..EventFields::default()
    },
);

前端收到后可以展示:

Create task “Review chapter 3”
Permission: Write

如果工具不是只读工具,Agent 会创建一个一次性的 confirmation ID:

let confirmation_id = Uuid::new_v4().to_string();

*control.confirmation.lock() = Some(ConfirmationState {
    id: confirmation_id.clone(),
    decision: None,
});

然后更新运行状态:

self.set_status(&control, RunStatus::WaitingForConfirmation);

最后发送:

AgentEventKind::ConfirmationRequired

事件中包含:

  • 工具名称
  • 权限等级
  • 参数预览
  • confirmation ID

此时 Agent runtime 停在确认点,Workspace 还没有被修改。

对应实现:Agent 等待确认流程 (line 700)


四、Tauri 只负责传递用户决定

React 前端消费事件后,将确认卡片展示给用户:

Agent wants to create a task.

Create task “Review chapter 3”

[Deny] [Allow once]

用户点击按钮后,前端调用:

core.submitConfirmation(runId, confirmationId, "Allow");

对应的 command 只传递三项信息:

submitConfirmation: (
  runId: string,
  confirmationId: string,
  decision: "Allow" | "Deny",
) =>
  command<void>("submit_confirmation", {
    runId,
    confirmationId,
    decision,
  }),

Tauri command 最终调用 Rust runtime:

pub fn submit_confirmation(
    &self,
    run_id: &str,
    confirmation_id: &str,
    decision: ConfirmationDecision,
) -> Result<(), AgentError>

Rust 侧不会盲目接受决定,而是验证:

let Some(pending) = confirmation.as_mut() else {
    return Err(AgentError::ConfirmationNotFound);
};

if pending.id != confirmation_id || pending.decision.is_some() {
    return Err(AgentError::ConfirmationNotFound);
}

pending.decision = Some(decision);
control.confirmation_changed.notify_all();

这里的 ID 校验很重要,它可以防止:

  • 旧确认卡片重复提交
  • 错误 run 的确认被提交到当前 run
  • 同一个确认请求被处理多次

五、用 Condvar 唤醒等待中的 Agent

Agent 等待确认时,并不是持续占用 CPU 轮询,而是通过 Condvar 等待:

fn wait_for_confirmation(
    &self,
    control: &RunControl,
) -> Option<ConfirmationDecision> {
    let mut confirmation = control.confirmation.lock();

    loop {
        if control.cancelled.load(Ordering::Acquire) {
            *confirmation = None;
            return None;
        }

        if let Some(decision) =
            confirmation.as_ref().and_then(|value| value.decision)
        {
            *confirmation = None;
            return Some(decision);
        }

        control.confirmation_changed.wait_for(
            &mut confirmation,
            StdDuration::from_millis(100),
        );
    }
}

当用户点击 Allow 或 Deny 时:

pending.decision = Some(decision);
control.confirmation_changed.notify_all();

等待中的 Agent 被唤醒,然后读取决定。

取消操作也会唤醒同一个等待点:

control.confirmation_changed.notify_all();
control.input_changed.notify_all();
control.events_changed.notify_all();

因此,Agent 不会因为用户一直不点击确认而忙等,也不会因为取消操作而永久挂起。


六、Allow 才能进入真正的 execute 阶段

如果用户允许执行,Agent 才会调用:

let result = self
    .tools
    .execute_for_sources(
        prepared.clone(),
        &self.workspace,
        self.clock.now(),
        &source_paths,
    )

这是整个设计中最重要的边界:

prepare() 不能写入
execute() 才能写入

例如 create_task 的 Workspace 写入只会发生在:

Invocation::CreateTask(args) => {
    workspace.upsert_task(task)?;
}

而不是发生在 prepare() 中。

对于资料读取类工具,execute 阶段还会收到当前 Notebook 的 source_paths,因此 Agent 不能绕过 Notebook 选择的资料范围。


七、Deny 不是让 Agent 崩溃,而是返回工具结果

如果用户拒绝,Agent 不会直接结束运行,也不会把拒绝当成系统异常。

它会生成结构化结果:

let result = json!({
    "ok": false,
    "error": {
        "code": "user_denied",
        "message": "User denied the requested operation"
    }
})
.to_string();

然后把这个结果作为工具消息回填给模型:

messages.push(ChatMessage::Tool {
    call_id: prepared.call_id,
    name: prepared.name,
    content: result,
});

对模型来说,这相当于工具返回:

{
  "ok": false,
  "error": {
    "code": "user_denied",
    "message": "User denied the requested operation"
  }
}

模型可以据此继续回复:

好的,我不会创建这个任务。

或者改为提出一个只读操作,而不是让整个 Agent run 失败。

这比直接返回普通字符串更好,因为模型可以根据稳定的错误码进行判断。


八、测试必须验证“确认前没有副作用”

仅仅测试“允许后任务创建成功”是不够的。

真正关键的是证明:

用户没有确认时,任务确实没有被创建。

工具层测试直接调用 prepare()

#[test]
fn create_task_is_declared_write_and_does_not_write_during_prepare() {
    let temp = tempfile::tempdir().unwrap();
    let workspace =
        Workspace::create(temp.path().join("Workspace")).unwrap();

    let registry = ToolRegistry::default();

    let prepared = registry
        .prepare(
            "call-1",
            "create_task",
            json!({
                "title": "Read chapter 3",
                "importance": 4
            }),
        )
        .unwrap();

    assert_eq!(prepared.permission, PermissionLevel::Write);
    assert!(workspace.read_tasks().unwrap().is_empty());
}

Agent 层继续验证完整流程:

assert!(workspace.read_tasks().unwrap().is_empty());

runtime
    .submit_confirmation(
        &run_id,
        &confirmation_id,
        ConfirmationDecision::Allow,
    )
    .unwrap();

let tasks = workspace.read_tasks().unwrap();

assert_eq!(tasks.len(), 1);

拒绝场景则验证两件事:

assert!(workspace.read_tasks().unwrap().is_empty());

assert!(events.iter().any(|event| {
    event
        .payload_json
        .as_deref()
        .is_some_and(|payload| {
            payload.contains(""code":"user_denied"")
        })
}));

也就是说,测试同时守护:

  1. 拒绝后没有数据写入。
  2. 拒绝结果确实以结构化错误返回给模型。

对应实现:确认前不写入测试 (line 1347)


九、最终执行链

整个过程可以概括为:

Model Tool CallToolRegistry::prepare()
      ↓
参数解析与校验
      ↓
PreparedTool + preview
      ↓
ToolRequested event
      ↓
非 Read 权限?
   ┌──┴──┐
  No    Yes
  │      ↓
  │  ConfirmationRequired
  │      ↓
  │  等待 Allow / Deny
  │      ↓
  │  Deny → user_denied
  │
  └──────┬──────
         ↓
execute_for_sources()
         ↓
ToolCompleted
         ↓
回填 ChatMessage::Tool

这套实现的重点不在于增加一个确认按钮,而在于把“模型提议”和“用户授权”变成两个不同的状态。

prepare() 保证参数可执行,ConfirmationRequired 保证用户看得到,submit_confirmation() 保证决定可验证,execute() 保证副作用只发生在授权之后。

结语

AI Agent 的安全问题,不能只依赖系统提示词:

You should ask for confirmation before writing files.

提示词可以被模型忽略,也可能因为上下文复杂而失效。

更可靠的方式,是把权限控制放进执行模型本身:

  • PermissionLevel 表达工具风险。
  • prepare() 隔离参数校验和副作用。
  • PreparedTool 保存已经校验过的调用。
  • 用事件通知 UI 展示待确认操作。
  • 用 confirmation ID 验证用户决定。
  • 用 Condvar 唤醒暂停中的 Agent。
  • 用结构化错误让拒绝可以回到模型循环。
  • 用测试证明确认前 Workspace 没有变化。

最终,Agent 不再是一个可以直接操作用户数据的黑盒,而是一个必须经过授权流程才能产生副作用的执行系统。