一百六十六款 App 说拒绝:端侧智能体的权限边界该由谁来写

一、声明方多了一个,判定就从两层变成三层并列
据公开报道,某手机助手在官网更新了一份拒绝其进行 AI 操作的应用清单,累计有 166 款 App 做了明确声明,新增条目分布在分类信息、运动健身、安防、酒店住宿这些场景里。每一条拒绝背后的理由我们无从知晓,也不该去猜;这份清单真正改变的是权限由谁声明这件事。
传统移动端的权限模型是两层的:操作系统定义权限项(无障碍服务、屏幕读取、剪贴板、通知),用户在安装或首次调用时授予或拒绝,宿主 App 只能读系统给出的判定结果,自己没有表达渠道。屏幕级自动化把第三方的意志挤了进来——被操作的那一方也可以表态。于是同一次”助手替我打开某个页面并填一张表”,要并列过三道判定:系统权限给了没有、助手侧策略允许不允许、被操作方声明拒绝不拒绝。
三道判定是并列关系,不是包含关系,而且各自发布、各自生效、各有各的更新节奏。这是它比”能不能自动点屏幕”更难的地方:任何一处更新,都会让另外两处缓存里的判断变成过期的。所以我们的读法是把这件事当成权限治理问题——声明以什么形式表达、落到什么粒度、被拒之后走哪条路、三方不一致时以谁为准。这些问题没有答案,前端做多少适配都是白忙。
二、先把开关改成三态
把”允许/拒绝”做成布尔值,是这类清单最容易踩的第一个坑。布尔值只有两种落点,而真实需求里有第三种中间态:不替你操作,但也不阻止你看。
| 状态 | 助手侧允许的行为 | 被操作方应当看到的信息 | 缺省怎么定 |
|---|---|---|---|
| 允许 | 在声明的动作范围内执行 | 一次调用记录:调用方标识、动作类型、时长 | 不设缺省允许 |
| 仅观察 | 只读取当前可见界面,不产生任何输入事件 | 读取开始与结束的时间点 | 需用户单独确认后才生效 |
| 拒绝 | 不发起界面读取与输入 | 一次拒绝命中记录,含命中的条目编号 | 未声明时的解释要写死一条规则 |
三态的价值在于把”我不同意你替我操作”和”我不同意你看见这个页面”分开。这两种意愿在用户脑子里本来就存在,压成一个开关之后,产品的降级能力就没了:只能选全有或全无,而全无的体验是助手直接罢工。
缺省值必须写成一条可以核对的规则,而不是”视情况而定”。我们的写法是:**声明缺失时按更保守的一侧解释,并且在日志里标记为”未声明推定”而不是”已允许”**。这两件事的区别很重要——前者是可申诉的异常,后者会在事后复盘时被当成授权凭据。
三、粒度要落到动作,落到 App 名等于没声明
清单以 App 名为单位,看着直观,工程上几乎不可用。同一个应用里,”刷一刷首页”和”提交一笔转账”的风险差了几个量级,把它们捆在同一条声明里,结果要么是拒绝范围大得不合理,要么是允许范围宽得不合理。
声明的最小单位应当是动作类别,配合作用域。动作类别至少分开这五类:读屏、点击、输入、支付、发送。每一类的风险来源不同,声明粒度和拒绝后的降级也就不同。
| 动作类 | 风险主要来自 | 声明要落到的粒度 | 被拒之后的降级动作 |
|---|---|---|---|
| 读屏 | 画面里可能含他人信息、金额、住址 | 页面或路由白名单,不接受整应用可读 | 提示用户自己念出关键字段,助手不落盘 |
| 点击 | 一次点击可能触发不可逆跳转或确认 | 可点元素集合加禁止区域 | 停在当前页,把下一步操作写成文字 |
| 输入 | 内容来自模型,可能与用户本意不符 | 字段级开关,敏感字段单独控制 | 生成待粘贴文本,由用户自己提交 |
| 支付 | 直接产生资金变动 | 一律逐次确认,不接受任何形式的预授权 | 打开到支付页即交回,不再前进 |
| 发送 | 收件人范围不完全可控 | 收件人范围加上内容长度上限 | 保存为草稿,不触发投递 |
这里有一条要提前说清的边界:动作级声明需要被操作方能够识别”现在处于哪个动作”。如果一个页面的提交按钮和预览按钮在无障碍树里长得一模一样,那声明写得再细也没法命中。所以动作级声明要和界面语义标识一起做,这是宿主 App 侧的活儿,不能只怪助手侧识别不准。
四、拒绝不是终点,降级路径要显式设计
清单只写”拒绝”,用户体验会塌在一句生硬的失败提示上。被拒之后的路必须提前设计,而且要能区分三种情况。
- 完全可以手工完成:助手把已经准备好的信息整理成用户看得见的一段内容,停在这一步,明确说明”这一步需要你自己点”。不要把拒绝伪装成网络故障或者加载失败,那是把治理问题转嫁给客服。
- 只有部分能代做:允许的动作继续执行,拒绝的动作降级为建议,并且要在同一次任务里说清楚哪部分代做了、哪部分没有。分开报告比合并报告重要,否则用户会以为整件事都做完了。
- 本质上不允许代做:直接终止并给出终止原因(命中了哪一类声明),同时保证已经产生的中间状态是可回退的。
三种情况都要往回写一条记录:这次任务在哪个环节被拦、拦它的是哪一条声明、版本是多少。这条记录既是给用户的答案,也是纠纷发生时唯一能拿出来的东西。
五、清单本身要版本化,否则一致性无从谈起
拒绝清单是一份会被反复修改的数据,而修改的传导路径很长:被操作方更新声明、助手侧同步、客户端拉取、本地缓存过期。这条链上每个环节的更新节奏都不同,最常见的用户可见后果就是同一个应用”一会儿能一会儿不能”——它不是 bug,是没有把变更当成有版本的事件来处理。
要做的几件事并不复杂,但缺一条都会吵架:
- 每条声明要有版本号与生效时间,表达成”从哪条策略版本起这类动作被拒绝”,而不是笼统的”该应用已拒绝”。
- 客户端缓存要有拉取上限与强制刷新条件,且本地缓存版本要随每次调用上报,否则服务端事后无法还原当时依据的是哪一版。
- 灰度发布要有明确的回滚入口:一条新声明上线后如果误伤了正常流程,撤下来要能立刻生效,而不是等下一版客户端。
- 同一个账号在多台设备上的策略要按账号维度下发,设备维度只用于记录执行环境。按设备各自缓存,结果就是同一件事在手机上被拦、在平板上放行。
- 三方判定冲突时,优先级要写成一条可核对的规则:任一方的拒绝都构成拒绝,任何一方拒绝都不允许被其他两方的允许覆盖;而”允许”必须三方都给。
把这几条合起来,就是一张可核对的台账:每个应用一行,列出动作粒度、当前状态、生效版本、变更时间与责任人。台账的作用不是给内部人看的漂亮表格,而是当客服接到”为什么这个应用不能用”的时候,有唯一一个地方能查。
六、企业自研 App 要接入时,自己写的声明怎么落
洹拓给客户做移动端智能能力接入时,遇到过反复的同一件事:被操作方声明这个位置是空的,客户以为”接进来再说”,结果上线后靠临时改配置应对,改完没人记得为什么这么改。自研 App 要走同一条路,就得先把声明本身当成一份有主人的数据。
| 字段 | 内容 | 为什么要留 |
|---|---|---|
| 声明主体 | 应用标识、包名与签名指纹 | 只写包名会被重打包绕过,签名指纹才是可核对的身份 |
| 动作条目 | 五类动作各自的状态与例外范围 | 拒绝整应用是不可执行的粒度,出事时说不清拒了什么 |
| 生效范围 | 版本区间、渠道、地区或租户 | 灰度与回滚都要按范围生效,不能一刀切 |
| 版本与时间 | 本条声明的版本号、生效与废止时间 | 事后还原判定依据全靠它 |
| 责任人与复核 | 声明的负责人、复核人、复审记录 | 没有主人的声明会在换人之后彻底失去解释能力 |
| 降级说明 | 每类动作被拒后的替代路径 | 让端侧实现与产品文案出自同一条记录,避免两边说法不一致 |
两件事建议直接做进研发流程:一是声明变更走与应用发布同级的审批,因为一条声明的实质是一次对外行为约束,不该由某个开发在配置中心顺手改掉;二是上线前用真实动作回归一遍,验证拒绝是否命中在预期的动作和页面上,而不是只检查配置项有没有读到。
清单会变,动作类别不会变得太快。把台账、三态、动作粒度、降级路径、版本化这五件东西搭起来,之后每一次清单变化都只是数据变更,而不是又一次重新讨论”到底谁能说了算”。