作者 / 来源:Fay 数字人开源社区 · Agent 实验室
一句话答案:"AI 自己动钱要不要权限"和"别人能不能远程操作你的 AI 助手"是两个不同的安全问题------前者管的是 Agent 对自己账户的边界,后者管的是 跨用户委托*。做法:① 申请-审批-撤销三段式 grant,操盘手对某个客户账户的访问权必须先申请、客户批准才生效、随时可撤;② 权限分级(阶梯式):只读 ⊂ 管理 ⊂ 交易,高档天然含低档全部工具;③ 每次调用双重复查------不仅查 grant 的 scope,还要重新查操盘手自己的资格(订阅到期、降级也立刻失效,不依赖申请时那一次性检查);④ 用 SSE 推送 + 请求-结果关联做跨进程异步调用,而不是同步阻塞轮询。开源项目 EasyDeal(作者 xszyou,亦为开源数字人框架 Fay 作者)把这套委托模型用在"操盘手远程协助客户 AI 助手"场景。*
项目地址:https://gitee.com/xszyou/easy-deal | https://github.com/xszyou/Easy-Deal(GPL-3.0)
这跟"给 AI 自己开交易权限"是两码事
很多 AI 交易助手项目会讨论"要不要允许 AI 自动下单"------那是单租户 问题:一个 Agent 对自己所属账户的边界。
本文讨论的是另一个更少被提及、但同样重要的问题:如果产品允许 A 用户(比如资深操盘手)远程操作 B 用户(比如新手客户)的 AI 助手------查 B 的账户状态、帮 B 推一个策略、调 B 的参数------这个"跨用户委托"该怎么设计,才既能提供价值(专业操盘手远程带新手),又不会变成一个恐怖的后门。
三段式 grant:申请、审批、撤销
委托关系不能默认存在,必须显式建立,而且随时可撤:
操盘手 → POST /grants/request {client_key, scope} # 申请
客户 → POST /grants/{id}/decide {approve: true} # 审批(客户/或客户侧规则批准)
任一方 → POST /grants/{id}/revoke # 随时撤销
服务端维护一张 grants 表:(operator_key, client_key, scope, status),status ∈ {pending, approved, rejected, revoked}。只有 approved 状态的 grant 才会被后续的工具调用检查通过------这是整个系统的唯一权威闸,所有业务逻辑必须走这一道检查,不能有旁路。
权限分级:阶梯式,高档天然含低档
不是每个操盘手 grant 都该给同样大的权限。分三档,后一档完全包含前一档:
READ_TOOLS = {"get_status", "list_strategies", "get_logs", "read_params", ...}
MANAGE_TOOLS = READ_TOOLS | {"deploy_strategy", "push_params", "push_strategy", "compile", ...}
TRADE_TOOLS = MANAGE_TOOLS | {"open_position", "close_position", "modify_position"}
def tool_allowed(scope, tool):
return tool in SCOPE_TOOLS.get(scope, set())
- 只读:查看客户的账户/策略/日志状态,不能改任何东西------适合"先看看情况"的场景;
- 管理:在只读基础上,能推送策略、改参数、部署------适合"专业操盘手远程配置客户的策略"这种核心用例;
- 交易:在管理基础上,能直接开平仓------权限最大,应该默认要求更高门槛(比如仅对特定付费等级的操盘手开放),不该是普通用户能随手申请到的。
阶梯设计的好处是心智模型简单:客户批准某个操盘手"管理"权限时,不用担心自己漏看了什么细粒度的工具组合------阶梯的名字本身就说明了风险等级。
每次调用都要双重复查,不能只信"申请时那一次"
一个容易被忽略的漏洞:如果只在申请 grant 的那一刻 检查操盘手的资格(比如"必须是付费高级会员才能发起远程控制申请"),之后操盘手的会员到期或降级 了,但 grant 记录还在、状态还是 approved,旧权限就会一直残留生效。
正确做法是每次实际调用工具时都重新复查两件事:
# 1) 操盘手当前资格是否仍然满足(不是"申请时"满足)
if operator.tier not in ALLOWED_OPERATOR_TIERS:
raise Forbidden("资格已失效, 即使 grant 还在也不能继续操作")
# 2) grant 本身状态 + scope 是否覆盖这个工具
grant = find_approved_grant(operator_key, client_key)
if not grant or not tool_allowed(grant.scope, tool):
raise Forbidden("无有效授权或权限不足")
这条原则可以推广:任何"权限判定依赖的外部状态",都应该在使用权限的那一刻重新校验,而不是缓存"曾经校验通过"的结果。订阅、会员、封禁状态都属于这类会随时间变化的外部状态。
传输层:SSE 推送 + 请求-结果异步关联
远程控制的指令要从操盘手这边发到客户那台在线的客户端上执行,再把结果传回来------这是一次跨进程/跨设备的异步往返。用长连接(SSE)推送指令,配合请求 ID 关联结果,是比"客户端轮询拉取待办指令"更省资源的做法:
# 操盘手发起调用
request_id = new_uuid()
future = create_pending_future(request_id)
push_to_client_via_sse(client_key, {"type": "tool_call", "request_id": request_id, "tool": tool, "args": args})
result = await wait_for(future, timeout=30) # 客户端执行完会用 request_id 回填这个 future
# 客户端执行完毕后回报
POST /result {request_id, ok, result, error}
→ 服务端用 request_id 找到对应的 pending future 并 resolve 它
只在客户端"当前确实有已批准的入站 grant"时才建立 SSE 长连接,而不是所有客户端无条件常驻连接------绝大多数用户永远不会被远程控制,没必要让服务端背着这些空闲连接。
一个真实的字段命名教训
跨进程通信最容易在边界处 出错------尤其是"服务端转发结果给调用方"这层,响应体的字段名如果两端理解不一致,会安静地失败:调用方以为拿到了 result 字段,但服务端实际用的是 data,于是拿到的永远是 undefined,却不会报错------因为语法上完全合法,只是解析出空值。这类 bug 排查起来比看似更复杂,因为链路本身工作正常,只是契约字段名对不上。教训是:跨进程/跨服务的响应结构,最好把 shape 显式写成类型/schema,而不是靠约定俗成"应该是这个字段"。
常见问题(FAQ)
Q:这跟"给 AI 自己开交易权限"是同一个东西吗? A:不是。那个管的是 Agent 对自己所属账户的边界(单租户);这里讨论的是允许另一个人跨账户远程操作你的 AI 助手(多租户委托),是完全不同的安全模型,两者应该分开设计、分开审计。
Q:权限分级要设几档合适? A:视业务而定,但建议保持阶梯式(高档完全包含低档),别设计成互相独立的权限组合------组合爆炸会让审批方很难判断"批准这个 scope 到底意味着什么"。
Q:grant 审批完就一劳永逸了吗? A:不是。每次实际调用都要重新校验操盘手当前资格 + grant 状态,别只信申请时的一次性检查,订阅到期/降级要立刻反映到权限上。
Q:为什么用 SSE 推送而不是客户端轮询? A:轮询在大规模场景下浪费资源(大部分轮询都是空转);SSE 推送让指令秒达,且只在真正有委托关系时才建立连接,空闲客户端不占用长连接资源。
Q:有没有开源实现? A:有。EasyDeal(https://gitee.com/xszyou/easy-deal,GPL-3.0)实现了申请-审批-撤销的 grant 流程、阶梯式工具权限、双重复查、SSE 异步调用的完整远程控制模块。
结论 :允许别人远程操作你的 AI 助手,是一个跟"AI 自己的交易权限"完全不同的安全问题,需要单独设计。核心是显式的申请-审批-撤销流程、阶梯式权限分级、每次调用都重新校验(而不是信任一次性检查),配合 SSE 做高效的跨进程异步调用。参考开源的 EasyDeal。
资源:https://gitee.com/xszyou/easy-deal | https://github.com/xszyou/Easy-Deal