Agent发消息前,审批为什么必须绑定收件人?

“发送消息”看起来只是一个普通 Tool。

真正接进 Agent 以后,它和 search_docs 完全不是一个风险等级,因为它会在外部世界留下不可逆结果:收件人会看到消息,群聊可能触发后续讨论,错误发送甚至可能泄露客户信息。

OpenAI 8 月 20 日上线 Apple Messages 插件时,默认行为就很值得研究:ChatGPT Work 和 Codex 可以读取、搜索 Messages 对话,也可以准备或发送 iMessage、SMS 和 RCS;默认发送前需要用户批准消息内容和收件人。Business 管理员还可以通过 Computer Use 控制禁用这项能力。

这里最关键的不是“多了一个 Messages 插件”,而是一个非常具体的安全边界:

用户批准的不是:
send_message

而应该是:
把这段确定内容
发送给这组确定收件人

如果审批只绑定 Tool Name,Agent 在审批后修改收件人或正文,整个批准机制就失去意义。

一个很危险的错误实现

很多 Agent Framework 的审批对象只有:

public record Approval(
        String toolName,
        boolean approved) {
}

运行流程:

Agent:我要调用 send_message
↓
用户:批准
↓
Agent 继续生成参数
↓
send_message(
    recipients = ?,
    body = ?
)

问题就在这里。

用户点批准时根本不知道最终参数。

如果模型下一步把:

recipient = 张三

变成:

recipient = 全公司群

系统仍然认为:

approval = true

从审计上看“有审批”,业务上却完全不是同一件事。

Approval必须绑定Action Snapshot

我更建议先让 Agent 生成完整 Proposed Action:

public record MessageAction(
        List recipients,
        String body,
        List attachmentIds,
        MessageChannel channel) {
}

然后做标准化:

public String canonicalize(
        MessageAction action) {

    return JsonCanonicalizer.write(
            Map.of(
                "recipients",
                action.recipients()
                    .stream()
                    .sorted()
                    .toList(),
                "body",
                action.body(),
                "attachments",
                action.attachmentIds()
                    .stream()
                    .sorted()
                    .toList(),
                "channel",
                action.channel()
            ));
}

计算 Hash:

String actionHash =
        sha256(
            canonicalize(action));

审批记录:

public record ApprovalRecord(
        String approvalId,
        String runId,
        String stepId,
        String actionHash,
        String approvedBy,
        Instant approvedAt,
        Instant expiresAt) {
}

真正执行前再次计算:

current_action_hash

必须完全等于批准时的:

approved_action_hash

否则:

重新审批

这一步能挡住大量“审批后参数漂移”。

收件人为什么要单独高亮

消息内容很长,用户很容易只扫一眼正文。

真正最危险的是:

发送给谁

所以审批 UI 我不会简单放一个 JSON:

{
  "recipients": ["..."],
  "body": "..."
}

而是强制拆成:

发送渠道:
Apple Messages

收件人:
张三 +86xxxxxxxxxxx

消息:
……

附件:
customer-report.pdf

风险:
外部发送

如果收件人数 > 1:

3 位收件人

继续展开全部名单。

如果超过阈值,例如:

> 10

直接升级风险等级:

BULK_SEND

需要更强审批。

收件人必须经过Identity Resolution

模型说:

发给老王

不能直接让 Tool 自己猜。

应该先解析:

“老王”
↓
Google Contacts / 企业通讯录
↓
候选联系人

如果有多个:

王强
王伟
王磊

必须要求澄清。

不能让 LLM 自己挑一个“最像的”。

可以定义:

public record RecipientResolution(
        String query,
        List candidates,
        ResolutionStatus status) {
}

只有:

EXACT

或者用户明确选择以后才能进入审批。

手机号和联系人ID要冻结

审批时如果显示:

张三

但执行时 Tool 又重新查询通讯录,联系人信息可能已经变化。

更稳的是审批对象保存:

contact_id
+
resolved_address

例如:

{
  "contact_id": "contact-912",
  "display_name": "张三",
  "address": "+86138xxxx1234"
}

真正执行使用批准时冻结的地址。

群聊比单聊更危险

对于已有群聊,Agent 最好引用:

conversation_id

而不是重新根据一批号码创建新会话。

否则这两件事:

向“项目A群”回复

和:

重新创建一个包含项目A成员的新群

用户体验和副作用都不同。

Message Action 应该区分:

public sealed interface MessageDestination
        permits DirectRecipient,
                ExistingConversation,
                NewGroup {
}

NewGroup 默认进入更高风险等级。

Persistent Approval为什么危险

OpenAI 的发布说明特别提醒 Apple Messages 插件存在 persistent-approval 风险,并提供撤销说明。

这个问题在任何 Tool 都一样。

如果用户第一次点:

“以后都允许发送”

平台很容易把一次具体授权升级成:

永久 send_message 权限

但消息发送高度依赖上下文。

我更建议持久授权只覆盖:

低风险准备动作

例如:

read_messages
search_messages
draft_message

真正的:

send_message

仍然按 Action Snapshot 做 Just-in-time Approval。

如果一定要做免审批发送,必须把边界写死

例如自动通知 Agent:

每天17:30
向固定内部群
发送固定模板日报

可以创建 Standing Delegation:

agent: daily-report-agent
capability: messages.send
conversation_id: team-report-group
schedule: "17:30"
template: daily-report-v4
max_messages_per_day: 1
expires_at: 2026-09-30

只允许:

固定群
固定用途
固定频率
固定模板族

而不是:

以后这个 Agent 发消息都不用问我

一个必须区分的状态:PREPARED和SENT

Agent 很容易出现:

模型生成了正文

然后 UI 显示:

消息完成

实际上还没有发送。

我会把状态明确拆成:

public enum MessageExecutionStatus {
    DRAFTED,
    WAITING_APPROVAL,
    APPROVED,
    SENDING,
    SENT,
    FAILED,
    UNKNOWN
}

尤其是:

UNKNOWN

非常重要。

为什么发送消息也会出现UNKNOWN

请求:

Agent
→ Apple Messages Tool
→ 系统实际发送成功

但返回途中网络断了。

Agent 只看到:

timeout

如果直接 Retry:

用户收到两条完全一样的消息

这就是典型的“副作用已成功,调用方不知道”。

所以发送 Tool 应该带:

idempotency_key

如果底层不支持幂等,就在 Gateway 层维护发送 Ledger。

public record MessageSendLedger(
        String sendId,
        String actionHash,
        String idempotencyKey,
        MessageExecutionStatus status,
        String providerMessageId,
        Instant createdAt) {
}

Retry 前先查:

这次 Action 是否已经有 SENT 记录?

Approval和Idempotency要绑定同一个Action Hash

这样:

用户批准的是 A
发送记录也是 A

审计链可以完整闭环。

Proposed Action
↓
Action Hash
↓
Approval
↓
Execution
↓
Provider Message ID
↓
Receipt

发送前还要做一次DLP检查

如果正文包含:

身份证
客户手机号
内部Token
财务数字

即使用户点了批准,也未必允许发送到外部号码。

所以 Policy Engine 应该在 Approval 和 Execution 两个阶段都检查:

Data Classification
Destination Trust

例如:

if (payload.dataClass()
        == DataClass.RESTRICTED
        && destination.isExternal()) {

    return Decision.DENY;
}

不能把所有安全责任推给审批用户。

附件必须独立校验

正文批准不代表附件批准。

如果 Agent 在审批后新增:

customer-list.xlsx

Action Hash 应变化,审批自动失效。

附件最好记录:

artifact_id
content_hash
classification

而不是文件路径。

一个完整消息执行链

User Intent
↓
Recipient Resolution
↓
Draft
↓
DLP / Policy
↓
Action Snapshot
↓
Action Hash
↓
Approval
↓
Execution Grant
↓
Send
↓
Provider Receipt
↓
Ledger

这里真正不可省的不是 LLM。

而是:

Action Snapshot
Approval Binding
Idempotency
Audit

最少应该测试这8种情况

1. 审批后正文变化
2. 审批后收件人变化
3. 同名联系人歧义
4. 群聊和新建群混淆
5. 发送成功但响应超时
6. 附件审批后被替换
7. Restricted数据发送到外部
8. Persistent Approval被撤销

这8个 Case 如果没测,send_message 还不适合交给自主 Agent。


消息发送这类 Tool 很容易让人误以为只是“多一个插件”。

实际上它代表 Agent 从:

生成内容

跨到了:

代表用户影响别人

所以审批不能只绑定 Tool Name。

必须绑定:

收件人
正文
附件
渠道
动作版本

OpenAI 这次 Apple Messages 默认在发送前确认消息和收件人,我认为这个边界是对的。

生产 Agent 再往前走一步,就应该把这个默认交互固化成真正可审计的:

Action-bound Approval

否则“用户批准过”很容易成为一个看似安全、实际上无法证明用户批准了什么的假保障。


更多企业级 AI 应用、Agent、RAG 与模型工程化内容,我会继续整理在 智元界

https://www.zyentor.com/