AI Agent 最有价值的能力,是能够调用工具完成实际任务。
但这也是最危险的部分。
当模型输出:
JSON
{
"name": "create_task",
"arguments": {
"title": "Review chapter 3"
}
}
这并不代表用户已经授权创建任务。它只能说明:模型"提议"执行这个操作。
如果程序拿到 tool call 后直接调用 Workspace 写入逻辑,模型输出就等价于用户授权。对于创建任务、写入文件、执行代码甚至删除数据来说,这显然是不安全的。
本文介绍一种更稳妥的执行模型:
rust
模型提出工具调用
↓
prepare:解析、校验、生成预览
↓
confirmation:等待用户授权
↓
execute:真正执行副作用
核心原则只有一句话:
Agent 可以提出行动,但不能替用户做决定。
一、权限不是模型提示,而是宿主侧策略
StudyPulse 首先为工具定义权限等级:
rust
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PermissionLevel {
Read,
Write,
Destructive,
Execute,
}
这些权限分别代表:
Read:读取资料、搜索 Workspace、读取任务Write:创建任务、写入 Agent memory、保存 artifactDestructive:删除或不可逆操作Execute:执行本机代码
工具定义同时携带权限:
rust
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub parameters: Value,
pub permission: PermissionLevel,
}
例如:
rust
definition::<CreateTaskArgs>(
"create_task",
"Create a StudyPulse homework or reading task.",
PermissionLevel::Write,
)
这里的权限字段可以发送给模型,帮助模型理解工具契约,但它本身不能作为安全边界。
真正的安全判断必须发生在 Agent runtime 中:
rust
if prepared.permission != PermissionLevel::Read {
// 请求用户确认
}
也就是说,当前实现中只有 Read 工具可以直接执行,Write、Destructive 和 Execute 都必须经过确认。
对应实现:PermissionLevel 与 PreparedTool (line 23)
二、prepare 阶段只负责"准备",不能产生副作用
工具调用进入注册表后,不会直接执行,而是先进入 prepare():
rust
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 阶段需要完成:
- 解析 JSON 参数。
- 校验标题不能为空。
- 校验重要性范围。
- 构造内部 invocation。
- 生成给用户看的 preview。
简化后大致如下:
rust
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,而是已经解析过的内部调用:
rust
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 事件:
rust
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()
},
);
前端收到后可以展示:
makefile
Create task "Review chapter 3"
Permission: Write
如果工具不是只读工具,Agent 会创建一个一次性的 confirmation ID:
rust
let confirmation_id = Uuid::new_v4().to_string();
*control.confirmation.lock() = Some(ConfirmationState {
id: confirmation_id.clone(),
decision: None,
});
然后更新运行状态:
rust
self.set_status(&control, RunStatus::WaitingForConfirmation);
最后发送:
rust
AgentEventKind::ConfirmationRequired
事件中包含:
- 工具名称
- 权限等级
- 参数预览
- confirmation ID
此时 Agent runtime 停在确认点,Workspace 还没有被修改。
四、Tauri 只负责传递用户决定
React 前端消费事件后,将确认卡片展示给用户:
rust
Agent wants to create a task.
Create task "Review chapter 3"
[Deny] [Allow once]
用户点击按钮后,前端调用:
rust
core.submitConfirmation(runId, confirmationId, "Allow");
对应的 command 只传递三项信息:
rust
submitConfirmation: (
runId: string,
confirmationId: string,
decision: "Allow" | "Deny",
) =>
command<void>("submit_confirmation", {
runId,
confirmationId,
decision,
}),
Tauri command 最终调用 Rust runtime:
rust
pub fn submit_confirmation(
&self,
run_id: &str,
confirmation_id: &str,
decision: ConfirmationDecision,
) -> Result<(), AgentError>
Rust 侧不会盲目接受决定,而是验证:
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 等待:
rust
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 时:
rust
pending.decision = Some(decision);
control.confirmation_changed.notify_all();
等待中的 Agent 被唤醒,然后读取决定。
取消操作也会唤醒同一个等待点:
rust
control.confirmation_changed.notify_all();
control.input_changed.notify_all();
control.events_changed.notify_all();
因此,Agent 不会因为用户一直不点击确认而忙等,也不会因为取消操作而永久挂起。
六、Allow 才能进入真正的 execute 阶段
如果用户允许执行,Agent 才会调用:
rust
let result = self
.tools
.execute_for_sources(
prepared.clone(),
&self.workspace,
self.clock.now(),
&source_paths,
)
这是整个设计中最重要的边界:
scss
prepare() 不能写入
execute() 才能写入
例如 create_task 的 Workspace 写入只会发生在:
rust
Invocation::CreateTask(args) => {
workspace.upsert_task(task)?;
}
而不是发生在 prepare() 中。
对于资料读取类工具,execute 阶段还会收到当前 Notebook 的 source_paths,因此 Agent 不能绕过 Notebook 选择的资料范围。
七、Deny 不是让 Agent 崩溃,而是返回工具结果
如果用户拒绝,Agent 不会直接结束运行,也不会把拒绝当成系统异常。
它会生成结构化结果:
rust
let result = json!({
"ok": false,
"error": {
"code": "user_denied",
"message": "User denied the requested operation"
}
})
.to_string();
然后把这个结果作为工具消息回填给模型:
rust
messages.push(ChatMessage::Tool {
call_id: prepared.call_id,
name: prepared.name,
content: result,
});
对模型来说,这相当于工具返回:
JSON
{
"ok": false,
"error": {
"code": "user_denied",
"message": "User denied the requested operation"
}
}
模型可以据此继续回复:
好的,我不会创建这个任务。
或者改为提出一个只读操作,而不是让整个 Agent run 失败。
这比直接返回普通字符串更好,因为模型可以根据稳定的错误码进行判断。
八、测试必须验证"确认前没有副作用"
仅仅测试"允许后任务创建成功"是不够的。
真正关键的是证明:
用户没有确认时,任务确实没有被创建。
工具层测试直接调用 prepare():
rust
#[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 层继续验证完整流程:
rust
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);
拒绝场景则验证两件事:
rust
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"")
})
}));
也就是说,测试同时守护:
- 拒绝后没有数据写入。
- 拒绝结果确实以结构化错误返回给模型。
对应实现:确认前不写入测试 (line 1347)
九、最终执行链
整个过程可以概括为:
vbnet
Model Tool Call
↓
ToolRegistry::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 的安全问题,不能只依赖系统提示词:
rust
You should ask for confirmation before writing files.
提示词可以被模型忽略,也可能因为上下文复杂而失效。
更可靠的方式,是把权限控制放进执行模型本身:
- 用
PermissionLevel表达工具风险。 - 用
prepare()隔离参数校验和副作用。 - 用
PreparedTool保存已经校验过的调用。 - 用事件通知 UI 展示待确认操作。
- 用 confirmation ID 验证用户决定。
- 用 Condvar 唤醒暂停中的 Agent。
- 用结构化错误让拒绝可以回到模型循环。
- 用测试证明确认前 Workspace 没有变化。
最终,Agent 不再是一个可以直接操作用户数据的黑盒,而是一个必须经过授权流程才能产生副作用的执行系统。