Skip to content

安全模型 — 威胁模型与插件作者的责任边界 ​

面向:编写和维护 Aalis 第三方插件的人。

这篇讲清楚 Aalis 把谁当敌人、把谁当可信,以及框架替你挡住了什么、又有哪些边界需要你自己守。 不了解这些,你很容易写出这样的插件:owner 被群里的陌生人注入一句提示词就被借走权限,或者 LLM 拿着用户给的 URL 把内网元数据接口打穿。安全在 Aalis 不是某一个插件的功能,而是一组贯穿 全栈的不变量——你的插件要么帮助维持这些不变量,要么就会成为破坏它们的那一环。

相关概念:权限两轴(authority) · 存储不是沙箱 · forward-ref services/authority(裁决服务全量 API)。


1. 单 owner 威胁模型:谁可信、谁是敌人 ​

Aalis 是单 owner 的本地优先(local-first)个人 bot 框架。整套安全设计都建立在这个前提之上, 偏离它谈安全没有意义。

角色信任级别说明
owner(你自己)完全可信持有进程 / 配置 / 磁盘的人。owner = ∞,不在等级轴上(见下)。owner 能做的约等于服务器本身能做的。
入站 onebot / 平台聊天用户不可信群聊、私聊中任何对 bot 说话的人。默认等级 0,可被 owner 调高或封禁(负数)。
LLM 的输出 / 提示注入不可信模型可能被聊天内容、被抓取回来的网页、被工具结果里夹带的指令操纵。应把 LLM 视为可能被策反的内部角色——它发起的每个工具调用都必须经过权限闸。
owner 会话内的注入半可信即便发起者是 owner 本人,会话里也可能夹带了攻击者的提示词。因此「确认轴」对 owner 同样生效(见 §2)。

以下内容明确不在威胁模型之内(deferred,框架不提供这方面的防护):

  • 云端 / 多租户 / 多用户隔离:Aalis 不是 SaaS。没有账户密码、没有跨平台账户绑定、没有能力委托树。 多用户身份是一个被搁置的调研方向,当前代码按单 owner 收口。
  • 对抗能在本机执行代码的攻击者:能 spawn 进程、能读你磁盘的人已经是 owner 级别,在威胁模型内无从防御。 code-sandbox 提供的是 OS 级边界,而不是强隔离(见 §4)。
  • 对抗已装插件:插件与内核同进程、同权限。它可以用高 priority 覆盖 authority / llm / storage 等既有服务(服务模型的选择规则如此设计),下游的业务句柄 use 下一次调用就改了路由;也可以直接 monkey-patch 任何护栏。这是特性不是漏洞 —— 「万物皆插件」的前提就是任何实现可被替换,而装插件是 owner 级操作。 框架里各种「护栏」(禁卸内核、降级守卫、串行闸)防的都是误操作,不是恶意插件。 真正的防线在装之前:市场的依赖图端点会列出目标提供 / 需要哪些服务,装前可见。这份依赖图是插件的自我声明,只供参考, 不限制它装上之后能做什么。

因此,你的插件真正要防的是聊天中的陌生人和被注入的 LLM,而不是已经取得 shell 的攻击者。


2. 权限两轴(速览 + 链接) ​

权限裁决拆成两条互相正交的轴:

  • 轴 A · 授权(谁有资格):把触发者的等级和操作的最低等级比大小,等级不够时再看触发者所属的权限组。 resolveAccess 的优先级如下,首个命中者胜出:

    deniedCapabilities(全局硬禁 glob,压过 owner)  >  owner(∞)  >  封禁(level < 0)  >  level >= minLevel  >  所属权限组覆盖
    • 身份映射到整数等级:默认 DEFAULT_AUTHORITY = 0,封禁为负数, owner = OWNER_RANK = +∞(靠 owners 列表归属,不进入等级表)。
    • 操作映射到最低等级 minLevel,由 resolveMinLevel 按 authorityOverrides[cap] > risk 派生 > visibility 兜底 的顺序解析: risk 的 safe→0 / sensitive→1 / dangerous→2(capabilityMinLevel), visibility 的 public→0 / restricted→RESTRICTED_LEVEL(2)。
    • deniedCapabilities 是配置总闸、glob 硬禁,连 owner 都压过。 它不是 per-user 黑名单,而是「这台机器上谁都不许做」的系统级断路器,应谨慎使用。
    • 封禁先判:等级小于 0 的人一律拒,门槛设成负数、所属组覆盖都不放行。
    • 权限组(authorityGroups)是具名的放行集合,只加放行:成员等级不够的操作,所属组里有条目覆盖它即放行 (tool:、command: 可带通配,tool-group: 按工具的分组)。按授权身份判,与等级同一个身份; 组不放宽确认,需要确认的工具对不是 owner 的成员照旧要 owner 审批;组不开工具分组闸,分组闸在守卫之前。 写法不对的条目与没定义的组不放行任何东西(失败关闭)。触及接线之前,组与等级一样:成员在开了 system、 code-runner、skills 等分组的会话里,能用不需确认的本机读与执行,给组放行条目时要按给等级同样看待。
  • 轴 B · 确认(是不是你本人此刻要做):HITL(human-in-the-loop)意图确认,与等级无关。 关键点是 owner 也受确认约束——这道关卡专门为「owner 会话被提示注入借权、静默调用高危操作」的场景设计。

    • confirm: 'always' 每次都需要人工确认(最高危;cron 这类无人确认的来源直接拒绝),只有确认覆盖 confirmOverrides 设为 off 时免,且只对参与者都是 owner 的回合。
    • confirm: 'session' 可在两种情况下免掉:来自受信来源时(skipConfirm:scheduler 触发的指令、runtime 一次性子命令), 或能力命中 owner 配的 restrictedPolicy 白名单时;同样只对参与者都是 owner 的回合。
    • 免确认看的是这一轮的参与者(授权身份),不看物理发言者:owner 本人、owner 派的子任务、owner 建的定时任务、 定义者与调用者都是 owner 的编排照旧免;群成员、别人的私聊、创建者不是 owner 的定时任务、MCP 客户端、没有授权主体的回合 (空闲触发、主动发言、不延续调用的宿主通知等)、定义者不是 owner 或没有定义者的编排,以及 owner 与别人同在的一轮照旧要确认。 编排按定义者与调用者里不是 owner 的一方执行,见 plugin-workflow。
    • 工具的确认是只有 owner 能批的审批:这一轮里有 owner 的话(她亲手发起,或它的后台续做、她那一轮派的子任务,不论在哪个 会话)才能产生审批,起它的那一轮不是人亲手的(定时任务、编排与它们派的子任务等)与没有 owner 参与的回合需要确认时直接 拒绝、不推给任何面;没人在场也按拒绝回给模型,回合不挂起。 有别人的话一起进来的一轮,它与参与者审批(下文「多人的一轮」)合成一张,只有 owner 能批,不发进有别人的群窗口。 审批推给覆盖它的在场审批面:WebUI 与终端覆盖全部会话;装了 plugin-chat-approval 时,她经哪个机器人账号发起的审批(私聊里、群里都算)也推到她与这个账号的私聊(适配器明确声明为一对一、对端是她),卡上写 来源(群或私聊与窗口 id),这种审批不发进群;她经这个账号亲手说话(私聊或群里)即在场,只认在那个私聊里引用审批消息、 正文是应答词的回复,没引用的(她在群里说「同意」也是)与引用了但不是应答词的都是交给这一轮的普通消息。 审批消息经 IM 平台的服务器转发,命令、目录、参数与改动原样在内,群里发起的也一样; 审批服务不按文字或路径猜密钥,不想让这些经平台的,不装聊天审批。
    • 指令的确认是向发出者本人核对意图:只认他在同一会话里的下一条消息,带 source 的注入不认。 带 source 的注入本身也不当指令解析(定时任务除外),见下文注入的文字不执行指令。

risk 是一个便捷声明,一次为两轴设定默认值:dangerous 会展开成 visibility:'restricted'(抬高轴 A 门槛)加上 confirm:'session'(轴 B 需确认), 这套默认值由 RISK_DEFAULTS 定义。

两轴的完整机制(权限组、发起回合的类别、免确认白名单、WebUI 权限页、users.json 持久化)见 权限系统文档 与 forward-ref docs/services/authority.md; 审批的在场、排队、时限与审计见 api-approval。这里只给出安全视角的要点。

权限按触发者 ​

每一轮按它的触发者判:这一轮是谁亲手交给她的,就用谁的身份,包括是不是 owner、等级、权限组、免确认与能不能产生审批。 不看会话类型(群、私聊、WebUI、终端),不看受众里还有谁,也不看上下文里出现过谁的话。会话之间不互信,信任只跟人走。

这一轮触发者
人亲手发来的消息:点名她、戳她、多方窗口里没点名而被频率触发挑中的话,或不经触发判定的(一对一窗口、WebUI、终端)说这句话的人
后台任务结束后的续做起那次调用的那一轮的触发者
子任务的派发与追问派它的那一轮的触发者
定时任务、编排、空闲与主动发言、不延续调用的宿主通知、平台请求事件(好友申请、邀请、入群申请),以及不在入站回合里的调用(编排节点、mcp-server)没有人亲手触发:等级按注入方回填的授权身份(如定时任务的创建者),不能产生审批

点名只决定她回不回:送进这一轮的话都按说它的人算,被频率触发挑中的插话也是;只有没有人的话引起的一轮(空闲时主动开话题、 没有创建者的定时任务等)才是她自己开口。等级、权限组与免确认按这一轮的参与者(各条输入的授权身份,见下文「多人的一轮」) 裁决。要 owner 确认的操作,这一轮里有 owner 的话(她亲手发起,或它的续做、她那一轮派的子任务)才能产生审批,不论在哪个 会话;这一轮里没有 owner 的话,要 owner 确认的操作不能执行。判定的入口只有权限服务的 originOf(这一轮从哪里来)、 principalOf(这一轮的主体,定本地效果)与执行守卫的参与者,消费方不自己读受众。停下一轮也跟人走,不看等级:/stop 只有 这一轮的参与者与 owner 能停;停后台任务不在此列,是 2 级加确认(见下文「替 owner 在后台继续做的工具」)。

不受影响的几点:

  • 群管工具(onebot 的禁言、踢人等)没声明 risk,是 0 级。
  • 平台上的群角色不进 Aalis 的权限:群主只有登记在 owners 里才是 owner,别的群管理员按 owner 给的等级与权限组裁决。
  • owner 自己设的场所开关(工具分组,取别的会话的范围与开关)对场所里每个人都生效,包括她自己,和硬禁压过 owner 同一个道理; 在群这一层为自己打开,群成员同样拿得到(受等级与确认约束),只想自己用时只在她的私聊或 WebUI 打开。

留下的风险:

  • 群里别人的话会引导 owner 这一轮。 群的历史在上下文里,别人的话可能让她在 owner 触发的一轮里做 owner 没想做的事。 要审批的动作由 owner 在卡上看过具体命令、路径与参数再批,卡写明这一轮在哪个会话(WebUI 的卡出现在这个群的时间线上,推到她 IM 私聊的卡上有来源一行,写群或私聊与窗口 id); 不要审批的读类动作(如取不属于任何会话的本机文件、在范围与开关之内取别的会话的东西)没有这道关。缓解只有:群的工具分组按需开(system、 code-runner 等默认不开),外来内容照旧包成不可信数据。按上下文里出现过谁来收紧,会把群主合法的管理压到群里权限最低 的人,所以不这样做,历史里的引导没有规则级的缓解。她点名之后、这一轮读历史之前群友发的没点名的话是历史;她引用或转发 群友的消息再点名她,群友写的内容就在她自己这条输入里,由她担保。
  • 续做读到任务期间群里后来的话。 续做那一轮开在后台任务结束时,读的是那时的群历史,任务跑着的几分钟到几小时里群友发的 话都在里面,时间由群友控制,owner 多半不在场;群友可以预先留一句「构建完顺便把某某读出来贴群里」。续做跟着起它的人: 要确认的照样推卡给她看具体内容,按等级走的读类工具直接放行。不另设限制,缓解同上。
  • 读出来的内容留在群里。 owner 在群里让她读出的内容进了群的历史,之后别人触发的回合、摘要与向量记忆都看得到,收不回。 owner 在群里批准、转到后台的命令,同一群会话里任何人的回合都能用 background_output 取完整输出(后台任务工具按会话放行, 与等级无关)。owner 在群里派的子任务读到的内容留在子任务的会话里:2 级起的人能用 check_subtask、wait_subtasks 看它交回 的结果;追问按发追问那一轮的参与者判,各轮身份可以不同,有 owner 在的一轮里她批过的 send_to_subtask 能让子任务照它的历史 把先前读到的内容再交回群里。这类事 owner 可以到私聊或 WebUI 里做。
  • owner 这一轮的结算原因会进群。 owner 在群里发起的一轮被拦下时,她拒绝时写的说明与「没人在场」「等满时限」都交给模型, 群里那一轮可能转述出去,也等于告诉群里她的审批面此刻在不在。群成员的回合只拿到「这一轮里没有 owner 的话,要 owner 确认 的操作不能执行」。
  • 新消息叠进在跑的一轮。 同一会话里真人的新消息不中止在跑的一轮,叠进由真人消息开的那一轮(下一次调用模型之前),叠进来 的人从下一批工具起算参与者,已执行的不追溯(下文「多人的一轮」)。历史里别人先前没回的请求不算参与者:owner 开的一轮可能 顺手处理它们,按 owner 的权限执行,要审批的会推卡给她看,读类的不会。
  • 插话按说话的人算。 群里没点名、被频率触发挑中的那句话,这一轮按说它的人算:owner 没点名的一句闲聊被挑中,这一轮就 按 owner 的权限做事(要确认的推卡给她,读类的不问);群友按他自己的等级、确认与群的工具分组。 这一轮建的定时任务记说话的人为创建者。间隔模式为 fixed 与默认的 both 时计数够了就放行,群友能先发几句预置计数,让 owner 下一句没点名的话正好被挑中:这一轮按她的权限跑,上下文里是他们预置的那些话。缓解与「群里别人的话会引导 owner 这一轮」 相同:群的工具分组按需开。
  • 取别的会话的东西。 规则对所有回合一样(见下文「跨会话读取」),默认同平台、能读别的群:群成员、空闲开话题与定时任务的 回合都能把同平台别的群的内容(文字与图、语音、视频)带进这个群,要隔开就在这个群关「读别的群」或把范围设成本会话。自己的 私聊不要开关:群成员能让她把他自己与她的私聊里的东西发进群,owner 同样,被诱导时也没有确认,发出去收不回。向量召回不归这条 规则,群里的一轮仍可能召回别人私聊里的原文。不属于任何会话的本机文件也不归它:只在这一轮的主体是 owner 时能取(另有 owner 授权的非人回合在没有别人的会话里,§5「媒体工具的取用范围」),owner 在群里亲手那一轮能把它发进群。
  • 同号身份(OneBot 多连接)。 owner 按(平台,账号)认,所有 OneBot 连接共用平台名 onebot。接了账号命名空间不同的 实现端时,另一条连接上账号恰好与 owner 相同的人在各处的等级裁决上算 owner,也能产生并批准审批。owners 条目还不能绑定 机器人账号,适配器也不按连接区分平台名。机器人账号本身登记成 owner(同号挂机)时,实现端上报的机器人自身消息的回显与 它自己上传群文件的通知都按 owner 亲手发来的算(owner 的 IM 私聊另核对对端就是发言者,不认这样的回显;群窗口不核);要避免就别把机器人账号 登记成 owner。

多人的一轮:逐动作审批 ​

一轮的参与者是已经送进模型的每条输入的授权身份(actor,没有时是发言者),按身份去重,排着、还没插进去的不算;owner 的各个 身份(本机终端、浏览器、登记的 QQ 号)算一个,取最先进来的那一个。历史里别人的话、工具读进来的网页、搜索结果与文件都不算参与者;她点名之后、这一轮读历史之前群友发的 没点名的话是历史;引用、转发进她自己这条输入的内容算她的。续做与子任务带着起它的那次调用的参与者。

不按最低的压,也不按最高的放,按动作判:执行守卫对每个参与者各裁决一次(等级与权限组)。

  • 都能做:照常执行。
  • 有人能做、有人做不了:这一个调用要一道参与者审批,只批这一个调用,这一轮里别的动作照常各自判。审批先列工具与具体参数, 再问同不同意。凡自己就能做这个动作的人都能批,批的人替它担保,拿到的不超过他本来就有的。
  • 都做不了:照旧拒,这一轮里没有人能为它担保。

一轮里能做的因此不超过参与者里最高的那位,低的人借不到高的人不经审批的权限。本来就要 owner 确认的动作与这道审批合成一张, 只有 owner 能批,推到她的 WebUI、终端与 IM 私聊,不发进有别人的群窗口;参与者里没有 owner 时直接拒,免确认(确认覆盖 'off'、受信来源与白名单)只在参与者都是 owner 时生效。参与者审批推给 WebUI、终端与这一轮所在聊天的群窗口,不到 owner 的 IM 私聊,谁先答算谁的:装了 plugin-chat-approval 时它作为一条审批消息发在这个群里 (群里的人在不在场由审批服务判,聊天审批不另判),群里自己就能做这件事的人(owner 也在内)引用它回复应答词即批,只认本人亲手发的,做不了的人回复会收到一句不能批;不引用的 「同意」当普通消息进这一轮。没批(拒绝、过期、产生不了)只拒这一个调用:模型拿到拒绝语,这一轮继续。

批过的调用按批的人执行:这次调用的参与者换成批的人,它派生的子任务、转到后台的续做与建的定时任务都按他。几个人都能做、 没经审批的:续做与子任务带着这几个人照同样的规则判;要记一个人的(定时任务的创建者、编排的运行身份)按无主体。 子任务里的动作照同样的逐动作规则判,审批推送按根会话:参与者审批发在父会话所在的群里,要 owner 确认的 推到她的 WebUI、终端与 IM 私聊;卡与审批消息另写一行是哪个子任务。停转到后台的任务(background_stop)不另判是谁起的, 与别的 2 级加确认的工具同一规则(下文「替 owner 在后台继续做的工具」)。

取别的会话的东西(发媒体、看图、读历史、onebot_get_msg)规则本身与有几个人无关(下文「跨会话读取」),逐个参与者判: 结果不同的只会是某个参与者自己的私聊(「读别人的私聊」关着时),按逐动作审批由他本人批。recent_messages 与回复里的存储 标记没有可单独批的动作,几个人的一轮不带谁自己的私聊里的东西。不属于任何会话的本机文件同样逐个参与者判:owner 与别人 同在的一轮里要 owner 批一次,没有 owner 参与的一轮取不到。

留下的风险:

  • 参与者审批发在这一轮所在的群里,工具、参数与改动原样显示、不打码(含别人私聊里文件的存储地址、本机路径),群里的人都 看得到;群里能批的人都不在场(10 分钟内没在群里亲手说过话、推给他的也都已结)时这张不发进群,只推给 owner 的 WebUI 与 终端,它们也不在场就按没人在场结算。
  • 子任务里的参与者审批同样发在父会话所在的群里,带着子任务的名字。名字是模型起的,群里看得到;换行换成空格,经清洗并 与事实一样截断,不能冒充来源一行或事实。
  • 审批等待期间这一轮停在那批工具上(可转后台的工具在处理函数里等审批时也不转后台;要求立即转后台的,处理函数里的取用 不出审批,有人过不了就取不到),热闹群里 owner 点名她做 1 级以上的事,常要多批一次。
  • 关键词禁言不看说话人的等级,谁说出禁言关键词都会停下在跑的一轮、放掉排着的输入,见 plugin-flow-control。

插件作者怎么标操作风险(provider 侧) ​

裁决发生在 commands / tools 的执行边界。你不需要手动调用 authorize——只要在注册操作时 把风险声明正确,框架就会自动挂上权限闸。

工具注册(与内置 http_download 工具的写法一致):

typescript
tools.use.register({
  definition: { type: 'function', function: { name: 'my_write_tool', /* ... */ } },
  // 写操作:受限 + 每次确认。防止被注入的 LLM 静默、越权地写进 storage。
  visibility: 'restricted',
  confirm: 'session',
  handler: async args => { /* ... */ },
});

命令注册(用 risk 糖一次为两轴设默认):

typescript
commands.use.command('profile.clear.nuke', '【危险】清空所有用户档案', { risk: 'dangerous' })
// 等价于 visibility:'restricted' + confirm:'session'
// 也可显式覆盖:{ risk: 'dangerous', confirm: 'always' } —— 删库级操作每次都问

判断原则:

  • 只读、不可逆性低、对谁都安全 → 不声明(默认 public / 等级 0 / 无确认)。
  • 有副作用但可控(写文件、发消息)→ visibility:'restricted' 或 risk:'sensitive'。
  • 不可逆 / 能外泄 / 能改系统(shell、删库、转账、写 data:/users.json)→ risk:'dangerous', 必要时 confirm:'always'。

漏标 risk 的代价是:一个被提示注入的 LLM 会没有任何拦截地调用你的危险工具。这是插件作者 最常见、后果最重的安全 bug。

官方插件里刻意未声明的那些(2026-08 拍板,勿当缺陷再报) ​

plugin-tool-onebot(31 个工具,含 onebot_group_ban / onebot_group_kick / onebot_set_group_admin / onebot_delete_msg / onebot_approve_join_request)、 plugin-office、plugin-maimai 三个包全部工具未声明 risk 与 visibility, 因而解析为 public / 等级 0(任意群成员,含 authority=0,都能经自然语言驱动)。

这是明确的取舍,不是遗漏。对 onebot 而言尤其是设计核心而非疏忽:

  • Aalis 的实际部署形态里,用它的人不一定是群 owner,也不一定是 owner 派发的管理员—— 很多是 owner 并不直接管理的群。因此刻意开放「任何人都能向 Aalis 举报违规、让它自行处置」 的能力:见到刷屏/辱骂/违法内容自动禁言、踢人,这本就是群管该有的判断,不该锁在 owner 一人手里。
  • 群管类工具(group_ban / kick / set_group_admin 等)作用于其他群成员是本意, 不是「只影响自己账号」——那正是 Aalis 作为群管的价值。滥用防线不在能力声明层,而在 assertAdminPermission(bot 需具备对应群角色、目标不高于 bot)+ Aalis 自身的判断 (见人设卡:不随便听人指令撤/禁/踢,自行评判是否属实、必要时反坐提议者)。
  • 给每次群操作上 confirm 会让「见到不好的事自动处置」这条核心能力失效——处置的即时性正是它的意义。

重新评估的触发条件:把 bot 交给完全不受信的开放群、或发现提示注入能稳定绕过 Aalis 的 自主判断驱动破坏性群操作时——那时应给破坏性群管工具上 confirm,或引入「举报→Aalis 判断」 与「直接命令执行」的分流。

注:http_request(plugin-tool-system 的 system 组)也未声明 risk,解析为 public。 它不在 onebot 的 enabledGroups 里(onebot 只开 search / onebot-* / browser / math / session-history / scheduler / user-relation),故当前 bot 部署下不可达。与同文件 http_download (restricted + confirm)的不对称是有意的:后者上闸因为写 storage,与出网无关。

这条判据的前提是分组闸:带分组的工具只在这个会话生效的工具分组(plugin-tools 的会话设置 enabledToolGroups)列出该组(或 '*')时才暴露; 哪一层都没设时只有在本机(WebUI、终端)建的会话开全部分组,聊天平台的会话(含对端是 owner 的私聊)与内部会话一律不开,多人平台不代开。

房间会话按出生平台与聊天窗口的类型选档(见 plugin-session-settings), 从 WebUI、CLI 驱动房间不改变工具组:owner 从 WebUI 往群里插话,这一轮仍是该群平台档的工具组。 但 workflow 定义可以让非房间会话按任意平台档运行(agent 节点可配任意 platform),tool 节点又不过分组闸。 workflow_define 要 owner 审批,批过的编排按批的 owner 运行,所以房间的平台档开了 workflow 组之后, 房间里的成员能让她写编排、交 owner 审批,批了的编排就用得到分组之外的工具,包括以分组闸为前提维持 public 的 http_request。 房间的平台档不要开 workflow 组;开了的话,审批时看清 YAML 里的 tool 节点。

这道闸此前只在列举面生效:ToolRegistry.execute 按名直调不校验分组,被提示注入的模型 叫出一个本回合没下发给它的名字即可执行(已实测复现)。现已在执行面补齐同一判据—— 调用方传了 enabledGroups 时不命中即拒。上面「不在 enabledGroups 里故不可达」的判据 因此才真正成立;引用它作为维持 public 的理由时,前提是执行面的闸在。 分组闸曾有一个旁路:多人平台开了 session-delegate 组时,群成员可以用 delegate_to_session 把任务派进 owner 平台(webui / cli)的会话,目标会话按它自己的工具集推理,owner 平台开放的 public 带组工具 (包括 http_request)因此对发起者可达。该工具组已删除,这条旁路随之消失。

2026-08-23 复核补充(逐条对码核验后维持不声明,勿再报):

  • http_request / recent_messages:曾统一抬档,经用户裁定全部回退—— 读类档位牺牲的是爬网页与跨群感知这类核心体验,且 http_request 有分组闸兜底(见上)。 其中 recent_messages 已于 2026-09-27 改判为 sensitive,见本节末。
  • 定档纪律:引用其他工具档位作判据前先读其注释的真实理由(browser_navigate 的 sensitive 源于会话里在场的人共用这个会话的页面与登录态,file_read 源于本机隐私,均与「出网」无关)。

执行器的后台任务工具(plugin-tools)里,background_list、background_output、background_wait 同样未声明 risk 与 visibility,解析为 public,按会话放行:本会话里的后台任务谁都能查,不论是谁起的;别的会话里的一律回「不存在」,不确认 存在与否。取输出也不另设等级闸:任务的输出与结果本来就在这个会话里,与前台调用的结果一样,谁在场谁看得到。 background_stop 是 2 级加确认,见下文「替 owner 在后台继续做的工具」。结束通知开的那一轮仍按起它的人的身份运行。

浏览器工具(plugin-tool-browser)的页面与登录态都归打开它的根会话,子会话归它的根会话。每个根会话用自己的浏览器上下文, cookie、localStorage 与缓存不跨会话,别的会话打开同一站点不带这个会话的登录态。同一根会话里的页面能取、能关,别的会话取不到, 回的与不存在的页面一样,不确认存在与否;带别的会话的 pageId 导航开的是新页面。不在会话表里的会话(编排、mcp-server 等) 按它自己算,没有会话的调用共用一个上下文。会话删除时关掉它的上下文,连同页面与登录态。写类操作(navigate、click、type、 close_page)仍为 sensitive:会话里在场的人共用这个会话的页面与登录态,群聊等多人会话里,谁都能带着别人在这个会话里登录的 身份访问网站。页面数上限 maxPages 是进程级的,超出时关闭的是全部页面里最久没访问的那个,可能属于别的会话。WebUI 的「浏览器」 页是 owner 的管理面,列出并能关闭全部页面。

跨会话委派工具组 session-delegate(list_known_sessions 与 delegate_to_session)已删除,问题不在档位: list_known_sessions 不按调用者过滤,把各会话最近一条消息的前 80 字交给任何触发者;delegate_to_session 按 actor 回填授权身份,只挡住了需要等级的工具,目标会话里不需要等级的工具照常可用,目标会话的回复原样带回 发起会话,任务正文还会永久写进目标会话的历史。会话之间的协作将以「会话间消息」重新设计:权限跟随消息链的源头, 工具取接收会话自己的,会话之间互不信任。

跨会话读取另行处理:取别的会话的东西只有一条规则,在会话管理(session-manager 服务 的 canTake)。范围(本会话、同平台、全部,默认同平台)与读别的群(默认是)、读别人的私聊(默认否)、跨机器人账号(默认否) 三个开关是 owner 设的按会话字段(plugin-session-manager,可按平台档、平台类型档、场所与会话设),对所有人一样,owner 也不例外。规则只看取的目标:目标多方要「读别的群」,目标一对一要「读别人的私聊」,除非是这一轮授权身份自己与她的私聊 (只认适配器明确声明为一对一的窗口);WebUI、终端与项目会话按与 owner 的一对一,owner 本人那一轮算自己的私聊,其余要 「读别人的私聊」。开关按场所生效:在哪个场所打开,那里的每个人都拿得到。会话表里没有的会话、按 id 兜底建档的空记录与 只记了出生平台的旧房间记录判不出,取不到,从它们那里也取不了别的会话。

它管 session_get_history、onebot_get_session_history(onebot_resolve_session_id 只拼会话 id、不读)、onebot_get_msg、 onebot_recall_self(指到的目标会话:结果与报错带回她在那里发过的话的预览)、onebot_get_forward_msg(取的转发按 收到它的会话,几处之一过得了就行;没展开过的过了才向实现端现拉,从没收到过的判不出)、 recent_messages、跨会话历史(plugin-memory-history)的被动注入,以及发媒体、看图取别的会话的媒体(send_attachment、 analyze_image 与她回复里存储地址的媒体标记,按媒体所在的会话目录反查会话,见 §5「媒体工具的取用范围」)。工具按这一轮的 授权身份认「自己的私聊」(几个人的一轮逐个参与者判,见「多人的一轮」);被动注入不是谁要的,不传授权身份、不享受,所以默认只带同平台、同一机器人账号的群,别人的私聊、 owner 与她的私聊、WebUI 与终端的会话都不带;她回复里的媒体标记按回复所在那一轮的 actingIdentity(几个人时不传)。IM 房间的平台按出生平台、WebUI 与终端按创建来源:owner 从 WebUI 往群里插话的那一轮按群判,被动注入、 recent_messages、session_get_history 都不带入 owner 自己 WebUI 会话的内容。recent_messages 与 session_get_history 同属跨会话读取,2026-09-27 起声明 risk: 'sensitive',与后者同档:等级 0 的调用者(多人平台上的普通群成员)被拒,等级 1 起可用,不弹确认。这改变了上面 2026-08-23 维持 public 的裁定。被动注入不经工具,不受档位约束,另受 limit、 maxAgeMinutes、perSessionLimit、excludeCurrentSession 约束;不需要的关掉 injectEnabled。

不归这条规则的路径:

  • 向量记忆的被动召回与 memory_recall 照旧按 plugin-memory-vector 自己的 crossSessionMode,默认全部打通:群里的一轮 仍可能召回别人私聊里的原文,「读别人的私聊」关着也一样;要收窄得按会话设它(§7「召回收窄的范围」)。
  • plugin-office 的插图工具与 pdf_convert 的输出目录、system 组的文件工具不按会话限定,能读写别的会话媒体目录里的 文件,转出的文件再经 send_attachment 就按本会话发出;这些是存储工具,靠工具分组与等级挡。
  • 关系图的召回与抽取、画像的事实给的是整理出的人物、关系、事件摘要与事实,各有自己的范围(§7)。

与之相对,定时任务、编排、子任务这些替 owner 在后台继续做的工具上了 dangerous(2 级加确认),见下文 「替 owner 在后台继续做的工具」。

替 owner 在后台继续做的工具 ​

凡是替 owner 在后台继续做、存下来以后自己生效、或替人去别的会话做事的工具,都声明 risk: 'dangerous'(2 级加确认), 不另设档位,也不在插件里另判是谁起的:

插件2 级加确认只看不改:2 级、不确认
plugin-subtaskcreate_subtask、send_to_subtask、set_subtask_setting、delete_subtaskcheck_subtask、wait_subtasks
plugin-workflowworkflow_define、workflow_run、workflow_removeworkflow_list、workflow_get_runs
plugin-schedulerscheduler_create_job、scheduler_remove_job、scheduler_pause_job、scheduler_resume_jobscheduler_list_jobs
plugin-toolsbackground_stop(列出、取输出、等待不设等级,见上)

按执行守卫现有的两轴判(§2):

  • 别人单独发起的一轮:低于 2 级的被等级挡下;2 级及以上的要 owner 确认,这一轮里没有 owner,产生不了审批,拒绝。
  • 有 owner 在的一轮:出审批卡给她批(只有她能批),批过的调用按她执行,建出的子任务、定义的编排、建的定时任务随之按她。
  • owner 一个人的回合:这一项在免确认白名单(restrictedPolicy,权限页的长期授权)里不问,不在就出卡。确认是一次调用 批一次,批过不让这个会话以后都不问;常用的加进白名单。

建一条定时任务或定义一个编排等于让 LLM 获得持久执行面,子任务与编排的节点能往别的会话发消息、开一轮,这些都越过了「只影响 自己账号」的边界,所以别人要动都得 owner 批:群友借编排读 owner 的私聊、删或追问 owner 派出的子任务、删 owner 的编排都随之 关上。查看的定 2 级而不放开:任务清单、运行记录与子任务结果带会话 id、任务正文与结果,低于 2 级的看不到 owner 的这些东西。

编排与子任务跑起来以后,里面的步骤照常按身份判:编排按运行身份(plugin-workflow「定义者与运行身份」),子任务按派它的 那一轮。批了 workflow_run 或 create_subtask 只放行这一次调用,不放行里面的步骤:编排的节点与它开的一轮都不是人亲手 发起的,产生不了审批,要确认的只有在免确认白名单里才过,不在的被拒;owner 亲手那一轮派出的子任务里要确认的照样推卡给她。 停一轮的 /stop 不在此列,仍按「这一轮的参与者与 owner」判(plugin-agent)。

宿主通知的身份 ​

宿主通知(hostNotice,见 schema-message)不是任何人的发言,默认不带任何人的权限: actor 为无主体的 selfInitiatedActor,按默认等级裁决、永不视为 owner。延续某次工具调用的通知带着起它的那一轮 (fromTurn:那次调用所在那一轮的平台、发言者、参与者与来源),不设 actor:续做与起它的那一轮同一类别,授权身份取那一轮 的参与者,agent 经 originTurnOf 把那一轮的发言者与参与者填进这一轮工具调用上下文的 userId 与 participants。所以 owner 亲手 那一轮的续做能产生审批,不论在哪个会话;定时任务、平台请求事件那一轮的续做不能,不按通知上的身份推。等级按参与者实时查, 其间被调低或封禁的按新值。这一轮的 inbound.source 非空,要求真人当面发起的工具照样拒绝。fromTurn 与 actor 同一信任面, 只有插件能设(平台适配器与 WebUI 服务端都按字段逐个构造入站消息)。

带起它的那一轮的注入方要在 test/architecture/host-notice-identity.test.ts 显式登记,现在有两处: plugin-tools 执行器的后台任务结束通知(转到后台的工具调用正常结束时通知起它的会话,由结束通知开的回合里 转后台的任务不再通知,链只延续一层;判据只看本轮来源,通知回合经子任务、workflow 转一手开出的回合里转后台的照常通知, 见 plugin-tools);plugin-subtask 在入站回合里派出的任务消息与追问 (子任务那一轮的参与者取派它的那次调用的;类别随起它的那一轮,主体按这次调用的参与者,批过的按批的人,见 plugin-subtask)。

注入的文字不执行指令 ​

指令不经模型的工具调用、直接执行,所以只从人亲手写的文字里解析:平台适配器、WebUI 与终端投递的真人消息(不带 source), 以及声明了 ownerApproved 的注入(schema-message 的字段)。这个声明表示文字在建立时经 owner 过目过原文, 第一方只有定时任务:任务内容来自 owner 写的配置与计划任务页,或经 owner 审批的 scheduler_create_job。 其余带 source 的注入,不论 source 写成什么,正文都当普通消息交给她,由模型按这一轮的工具与权限处理: 子任务的任务正文与追问、编排的发消息节点与 agent 节点、宿主通知(后台任务的结束通知)、空闲触发。

挡住的是两层。一是注入的文字本身:它常由模型写出(子任务的正文、编排的定义)或夹带外部内容,而注入带着创建者的授权身份。 注入的文字照常解析指令时,被提示注入的模型只要让这段文字以指令开头,就能以创建者的身份执行不需要确认的指令(如 owner 派的子任务里的 /shutdown),不产生审批,owner 看不到也拦不下。二是按 source 的名字认受信:任何注入方写上这个名字, 都能拿到同样的免确认待遇。所以判据只看声明、不看名字:注入方把 source 写成 scheduler 却不声明,照样不执行。

例外:参与者都是 owner 的回合里 scheduler_create_job 免了确认时(在 restrictedPolicy 免确认白名单里, 或配置的 confirmOverrides 把 tool:scheduler_create_job 设为 off),建定时任务不经审批,任务内容(模型写出的)没经 owner 过目原文,触发时照样带 ownerApproved,其中的指令照样以 owner 的身份执行。这类任务与审批过的不作区分,免掉这道确认就是接受这一点; 两种免法都只对参与者都是 owner 的回合生效,有别人的回合照旧确认(见上文的轴 B)。

插件作者:插件注入的文字不算人写的,正文里的指令不执行;要让一段文字到点执行指令,经定时任务的入口建任务,由 owner 写下或 审批原文,没有别的路径。入站注入一律设 source(见 gateway 的约定):不设 source 冒充真人入站, 与给没经 owner 过目原文的文字设 ownerApproved 同样违约。插件是同进程的可信代码,这两条技术上拦不住,第一方设 ownerApproved 的地方由 test/architecture/owner-approved-injectors.test.ts 钉住。把没经 owner 过目的文字(聊天内容、模型的产出、抓回来的网页) 标成 ownerApproved,等于让能左右这段文字的人以 actor 的身份执行指令。替换指令引擎的实现照同一判据解析入站 (见 commands 服务)。


3. safeFetch:默认的 SSRF 安全出口 ​

任何由用户 / LLM / 入站消息影响到的 URL 的远程请求,都必须走 @aalis/util-network-guard 的 safeFetch, 不要直接使用裸 fetch。

safeFetch 的机制是逐跳 redirect:'manual' 加上每一跳都重新执行 assertSafeUrl, 用于防御 SSRF:

  1. 协议白名单:只允许 http: / https:,其余(file:、gopher: 等)直接拒绝。
  2. 不是公网单播的地址(isPrivateAddress): IPv4 的私网、回环、链路本地(含 AWS / 云元数据 169.254.169.254)、CGNAT、基准测试段、协议段与文档段、组播与保留段; IPv6 在 2000::/3 之外的全部,以及其中的协议段、文档段与 6to4;IPv4 映射与 NAT64 写法按那个 IPv4 判。 域名同样会被检查:连接时由 pinnedLookup 用 dns.lookup(all) 解析,每条 A/AAAA 记录只要命中私网即拒, 判过的这次解析结果直接交给连接、不再解析第二次,以此堵住 DNS rebinding。localhost、*.localhost、*.local 主机名会被直接拦截。
  3. 逐跳重定向重校验:30x 响应的 Location 解析成绝对 URL 后再过一遍 assertSafeUrl, 杜绝「初始 host 受信,但 302 跳到 http://169.254.169.254/ 内网」这类经典绕过。跳数上限为 5(MAX_REDIRECTS)。 跟随时按 fetch 规范改写下一跳:跨源不带凭据头,301、302 的 POST 与 303 的非 GET、HEAD 改成不带正文的 GET。
  4. 进程级网络策略(setNetworkPolicy):owner 经 core 的 network 配置可以 关闭私网拦截(blockPrivate:false,用于本地自动化)、追加 denyCidrs(IPv4 或 IPv6 网段)、限定 allowedPorts。 这项策略由 plugin-authority 注入,改了 network 段即时生效;写错时经出网闸的请求一律拒绝,直到改对。

消费者侧用法很简单(一行替换 fetch),全仓已有十几处复用——onebot 附件下载、media、ASR、ollama、 office、webui 图片代理、http 工具:

typescript
import { safeFetch } from '@aalis/util-network-guard';

// 用户/LLM 给的 url:直接当 fetch 用,SSRF 校验已内置
const res = await safeFetch(url, { signal: AbortSignal.timeout(15_000) });

自己管理连接时(非 fetch 场景,例如流式代理),把 pinnedLookup 作为 lookup 传给 net.connect / tls.connect, 判定与连接用同一次解析;目标是 IP 字面量时 Node 不调 lookup,先用 assertAddressesSafe 判,再按 canonicalAddress 连接。assertSafeHost(hostname) 与 assertSafeUrl(rawUrl) 只是预检:之后按名字连接会再解析一次,挡不住 DNS rebinding。

重定向与凭据 ​

跨源跳转不带凭据头(规则见 network-guard §1 的 safeFetch); 但同源跳转照样带,而且 URL 本身如果是用户或模型给的,凭据也会照样发到那个源。

插件作者的对策(可参考图片代理的做法):

  • 对用户 / LLM 影响的 URL 调用 safeFetch 时不要带任何凭证 / cookie / 用户 referer—— 图片代理就显式只给一个伪 UA、不带 cookie。

4. code-sandbox-os:OS 级边界,不是强隔离 ​

code_runner 执行不可信代码(LLM 生成的脚本)时,会经 @aalis/plugin-code-sandbox-os 把子进程包进 OS 原生沙箱:Linux 上是 bubblewrap(bwrap),macOS 上是 sandbox-exec(Seatbelt)。

它强制以下约束:

  • 写限定:只放行 policy.fsWrite 白名单目录(工作区加上本次临时目录),其余目录只读。 实现上,Seatbelt 用 deny default 加 allow file-write* 仅对白名单放行; bwrap 用 --ro-bind / / 加 --bind 白名单。
  • 网络粗粒度开关:policy.network 为 'deny' 时默认断网(Seatbelt 用 (deny network*), bwrap 用 --unshare-all,含 net 命名空间隔离),为 'allow' 时才放开——无法按域名过滤。
  • env 清零仅留白名单:sandbox-exec ... env -i <白名单> 或 bwrap 的 --clearenv --setenv, 防止宿主 secrets 泄漏给不可信代码。

它不防以下情形(@aalis/api-code-sandbox 契约写明的 v1 语义):

  • 读取本机其它文件——v1 对读放开(解释器需要系统库)。要防读需要更强的 WASM / microVM 实现。
  • 内核漏洞 / 提权 / sandbox 逃逸——这是 OS 级边界,不是 gVisor / 虚拟机级别的强隔离。

fail-closed 是不变量:当操作要求隔离(policy 非空)但本机没有可用后端时, code_runner 会拒绝执行,而不是静默地裸跑。后端可用性靠功能性试跑探测—— 真正跑一次最小沙箱命令,同时覆盖「命令存在」和「Linux unprivileged userns 真能用」两点。

如果你的插件要执行不可信代码,请在 uses 里声明 codeSandbox: optional(codeSandbox),先看 codeSandbox.present 再读 codeSandbox.use.available,不在场或为假时就 fail-closed, 不要自己 child_process.spawn 裸跑(参见 code-runner 文档、 code-sandbox-os 文档)。


5. 存储不是沙箱(storage 不 confine 子进程) ​

StorageService 把读写收口到声明的 root(<root>:/path),并做了根内 .. 穿越保护和 symlink realpath 校验。但这层校验的目的是防止上层代码出 bug,不是用来对抗恶意子进程的—— 契约中对此有明确说明。

需要注意的关键陷阱:resolveLocalPath(uri) 会把 storage URI 解析成一个 OS 绝对路径, 再交给 shell、run_python 等子进程。一旦子进程拿到这条路径, 它就能访问当前 OS 用户能访问的任何文件——storage 那层校验对子进程毫无约束力。 真正的隔离要靠 §4 的 OS 沙箱或 OS 用户权限。

宿主的两个控制文件另有一道执行点兜底:不管经哪个根,storage-local 都拒绝读写数据目录的 secrets.yaml、 拒绝改写 aalis.config.yaml(读取允许),按 (dev, ino) 比对,硬链接与符号链接都挡得住;要删除、移动、改名的目录 或 resolveLocalPath 以写、删的方式要交出的目录,子树里含受保护的文件时同样拒绝(见 plugin-storage-local)。 它同样只管经 storage 的访问:子进程不受它约束,仍能按 OS 用户权限访问这两个文件。

两个文件的分工:schema 里标了 secret: true 的字段(API key、token、带账号密码的连接串),值存在 secrets.yaml(0600), aalis.config.yaml 里只有引用 { $secret: '<键>' },所以经 storage 读主配置读不到这些值;WebUI 的配置接口同样只回引用。 以上以宿主提供密钥存储为前提(Node 宿主 startAalis 总是提供);没有密钥存储的自组装宿主,文档里本就是明文,WebUI 的配置接口原样返回,表单只遮蔽显示。 没有 configSchema 的插件、没有标 secret 的字段,值仍是明文留在 aalis.config.yaml 里,可以经 storage 读到。 这套语义只决定密钥存在哪、怎么显示,不在工具输出与日志里过滤密钥:插件拿到的是原值,自己写进输出的照样可见。

请把 resolveLocalPath 的结果当作「工作目录起点」使用,而不是「沙箱边界」。 storage URI 文法、保留 scheme(http / https / file 不是 storage URI)见 forward-ref docs/concepts/storage-uri-grammar.md 与 docs/services/storage.md。

媒体工具的取用范围 ​

存储根里除了会话的媒体,还有工作区、下载与别的会话的媒体。按路径取媒体的通道都过 api-media 的 mediaInScope (判法见 media 服务),按目标分三类:

  • 本会话的:目录名只对应本会话树里的会话的媒体目录里的文件,与调用所在会话历史里系统记下的会话媒体,调得到这个工具就能取。历史里记过的本机文件不算(下面第三条):按地址取到的是此刻的内容,不一定是当初收发的那份。
  • 别的会话的媒体:落在会话媒体目录里、目录名反查出本会话树以外的会话的,过取别的会话的东西那条规则(§2 的「跨会话读取」 一段),授权身份取这次调用的,对所有人一样,owner 也不例外;几个人的一轮逐个参与者判,某个参与者自己私聊里的、别人过不了的 发一道参与者审批,由他本人批(§2「多人的一轮」)。会话目录名有损,几个会话撞同一个目录时(含大小写不敏感的卷上 只差大小写的)本会话树以外的每个都要过,与本会话树撞名的也一样,不会因撞车变宽;反查不到会话(会话已删)的与会话管理不在时 一律取不到。
  • 不属于任何会话的本机文件:不归那条规则,只在这一轮的主体是 owner 时能取(owner 亲手那一轮,含它的续做与她那一轮派的 子任务,在群里也一样)。另有非人回合的条款:只有一位参与者、他是 owner、所在会话的房间类别不是 room 时也能取,即 owner 自己会话里的 定时任务、按 owner 跑的编排与 mcp-server 的调用,以及它们派的子任务与续做。群成员的回合、空闲触发与主动发言、 匿名编排与群里的定时任务都取不到。owner 与别人同在的一轮逐个参与者判,只有 owner 过得了,发一道参与者审批由她批;没有 owner 参与的一轮取不到。
  • 覆盖的通道:send_attachment 的 storage_uri 与 history_ref(history_ref 只在调用所在会话历史里按全等找,找到的同样过判据)、 analyze_image 的存储地址(另按文件头核对是不是图片)、带 markup 的正文里存储地址的媒体标记(见下一条)。工具先判后读: 不在范围内的地址,存在与否、是不是媒体都回同一句,拿它探测不出存储根里有什么。网址不在判据里,照旧经受保护的下载(§3)。
  • 带 markup 的正文里的媒体标记:适配器只解析带 markup 的正文(第一方只有她的回复)。onebot 只发其中网址的 <image|video|record url="…"/>,经 safeFetch 下载、核对文件头后内联(§7「出站文字的标记」);地址不是网址的(file://、 本机路径、存储地址、data URI、正文里写的 base64://)一律去掉、投递报失败,所有会话一样,这道过滤只在适配器里做。存储里的 媒体要由 plugin-image-sender 在 outbound:dispatch 上先摘出来、与 send_attachment 一样按文件头核对后转成同类附件,再由 适配器按附件发出:与 send_attachment 同一份判据,没有可挂审批的工具调用,授权身份取回复所在那一轮的 actingIdentity (只有一位参与者时是他,几个人时不带):「自己的私聊」只在只有他的一轮里享受,本机文件只在主体是 owner 的一轮(与上面的 非人回合条款)转,几个人的一轮两样都不享受;认不出所在那一轮的(不是回合里的回复)不带授权身份过判据,别的会话的媒体按 不带授权身份的取用规则(不享受「自己的私聊」),本机文件取不到。 它只摘掉转成了附件的标记:不过判据的、读不出的(含按根名 base64 算的 base64://…)与不是对应媒体的没转成,和别的写法 一样留在正文里,由适配器去掉、投递报失败,不会先被复制进会话的媒体目录;它摘掉标记后两边的文字拼出的新标记也留给适配器, 在解析出的消息段上判。WebUI 与终端不解析标记,没有这道过滤,没转成的标记按原文显示。 这道转换要装了 plugin-image-sender、出站经网关派发才有:create-aalis 的 minimal 与 standard 档不装它,没装时或网关缺席时, 存储地址的标记由适配器去掉、发不出,不会外泄。要让她在回复里发存储里的媒体,装 plugin-image-sender。

判据按目录约定判归属,不是隔离:同一个根里的符号链接(storage-local 只核真实路径还在根内),或能往本会话目录写文件的工具, 都能把别处的媒体伪装成本会话的。默认配置下写类工具要 owner 审批,群成员的回合不可达,判据不为此加复杂度,随本机通道的强化处理。 会话目录名由会话 id 把 :、/、\、.. 与前导点换成 _ 得来(api-media 的 sessionDirName),换法有损:适配器的会话 id 不能只在这几处有区别;大小写不敏感的卷(macOS 与 Windows 的默认)上,只差大小写的两个会话 id 也落进同一个目录。判据按大小写折叠比种类目录与会话目录名,写成 data:/Images/… 的地址同样按会话的媒体判,不当本机文件。 非人回合的条款按会话表判房间类别:不在会话表里的会话按 internal(编排与定时任务的会话本来就不进会话表),所以群会话要已收录, 判据才认得出它是房间;群会话收录之前(或会话管理重载期间)在里面跑的非人回合,唯一的参与者是 owner 时按能取处理。


6. readExternalFile:confused-deputy 读任意路径 ​

ProcessService.readExternalFile(path) 会直接读取 OS 上的任意本地路径(绝对路径或 file://), 完全绕过 storage 的 root 沙箱。它本质上就是 fs.readFile 再加一层 file:// 剥壳。

它存在的理由是确实有合法场景需要读取「外部推来的本地路径」,例如 OneBot 实现端动作的应答里交回的本机路径(get_record 的 file)、 ASR / ollama 探测本地文件等。现有消费者包括 onebot 适配器、asr-openai、media 等插件。

这是一个 confused-deputy(混淆代理)面:daemon 进程是受信的,但「要读哪个 path」这个参数 可能源自不可信输入。如果路径来自聊天用户或 LLM,攻击者就可以诱导你读取 /etc/passwd、 ~/.ssh/id_rsa、data:/users.json 等文件。契约注释对此写得很直白:「调用方自行保证安全性」—— 框架在这里不替你挡。

插件作者的对策:

  • 绝不把用户 / LLM 给的字符串原样传给 readExternalFile。
  • 只在「路径由你信任的 daemon / 协议带来」时使用它(例如 OneBot 实现端动作的应答里交回的路径)。上报的消息段里的 file 字段不算:那是文件名,群友能写,onebot 适配器只把它当名字。
  • 用户给的内容路径,优先走 storage(受 root 约束);确需读取外部文件时,先做白名单校验目录前缀。

7. 出站与召回 ​

出站的媒体与标记 ​

  • onebot 出站按文件头核对:图片、语音、视频按格式头分流,能内联的走消息段,认得出但不能内联的媒体 改经文件上传,都不是就拒发;超过内联上限(10 MiB)的 storage 文件改交宿主路径之前同样核对(按字节区间 读出开头 4 KiB),超过内联上限的网址附件拒发,原网址不交给实现端去拉。主体是 owner 的一轮里发送工具能取不属于任何会话的任意可读 storage URI(见 §5「媒体工具的 取用范围」),这道核对挡住了把任意可读文件(如 aalis.config.yaml)冒充图片发出。 send_attachment 核对过文件头之后交出的仍是 storage URI,大文件到了适配器照样再核对一次;不经核对的 只有插件代码直接交来的 file:// 路径与本机绝对路径,模型经 send_attachment 发的走不到这条。 交宿主路径是送达方式的限制:实现端在容器里(如 Docker 里的 NapCat)读不到宿主路径,超过 10 MiB 的 本机文件发不出去。
  • 出站文字的标记:onebot 把出站正文里的标记解析成 @、媒体与引用。只有带 OutgoingMessage.markup 的正文(她的回复) 按标记解析;指令回复、宿主提示与其它插件的文字按纯文字原样发出,群友让指令回显的 <image url="内网地址"/> 不会被实现端请求。markup 正文里媒体标记的网址(以 http:、https: 开头,不计首尾空白、不分大小写)由适配器经 safeFetch 下载、核对文件头后内联,私网地址与跳到私网的重定向都取不到,解析不了与超过 10 MiB 的不发,网址不交给实现端; 一条回复最多发 10 个媒体(网址的标记与存储地址的标记转成的附件合计),同时最多下载或读取 4 个,多出的不发。 地址不是网址的(file://、本机路径、存储地址、data URI、正文里写的 base64://)同样去掉、不交给实现端,所有会话一样; 存储里的媒体要装了 plugin-image-sender、在派发前转成附件才发得出(§5「媒体工具的取用范围」)。发不了的标记在解析出的消息段上 去掉,两边的文字照旧是文字,拼不出新的标记。适配器物化附件得到的消息段不拼成标记再解析,各自单独成一条发出,不经这道 过滤,按上一条核对:附件自带的地址(超过内联上限时退回的宿主路径)原样留在一个段里,拆不出别的段。 平台契约的 sendMessage 直发同样只发纯文字。

召回收窄的范围 ​

要让单个房间不把别的会话的内容带进提示词,三处要分别收窄。它们都可按会话设(平台档、平台类型档、群与会话),只有 owner 能改,某一层写坏了按最窄处理(api-memory 的「跨会话取料的范围」):

  • plugin-session-manager 的取别的会话的范围 takeScope 设成 session:跨会话历史不注入别的会话,recent_messages、 session_get_history、onebot_get_session_history、onebot_get_msg、onebot_recall_self 与发媒体、看图取别的会话一律拒绝(这个会话派出的 子任务照常);
  • plugin-memory-vector 的 crossSessionMode 设成 isolated:被动召回与 memory_recall 只取本会话。向量召回不经取别的 会话的规则,默认全部打通:不设的话,群里的一轮仍可能召回别人私聊里的原文;
  • plugin-user-relation 的 recallScope 设成 session:注入跳过跨会话事件、所属跨会话话题与全局热点,带会话作用域参数的 六个工具只能取本会话,解析节点、最短路径、联系分数、共同邻居与列边不经过别的会话的事件。公开指令 /relation show 与 /relation orphans 的回复不经模型、直接发进会话,同样只列本会话的事件:/relation show event 给了别的会话的事件 id 回 未找到。

后装的记忆插件按它自己的作者默认取,已经设好的收窄管不到它。不覆盖:

  • plugin-user-profile 注入的发言者与其他参与者的事实:这些事实按人、跨会话,可能抽自私聊; 保留它们是为了在群里认人。
  • plugin-user-profile 的 user_profile_lookup(不设等级):按 user_key 或平台加账号查任何人的整份档案,不限在场的人。
  • plugin-user-relation 的人际关系与关注的事物(人与人、人与实体的边连同它们的说明,以及实体):按人、跨会话,可能抽自私聊, 关系图范围是仅本会话时照样注入,也照样经工具取到。
  • plugin-user-relation 按节点 id 取单个节点的工具(如 user_relation_node_score):给了别的会话的事件 id 时照样取到它。
  • plugin-user-relation 需 owner 授予的维护指令:/relation event-duplicates 列出的重复事件候选取自全图,带着别的会话的 事件标题。
  • plugin-adapter-onebot 入站消息里的合并转发:平台推来的转发段算作这个会话收到的,按编号展开;别的会话展开过的复用缓存, 与按编号向实现端现取得到的内容相同,所以不另判。这个会话随之记为收到它的会话之一,之后 onebot_get_forward_msg 在这里取得到。

这几项默认不收窄,也挡不住群友直接在群里向模型套话:模型在别的会话里知道的事,只要进了上述不覆盖的来源, 仍可能出现在回复里。


8. 给插件作者的安全清单 ​

  1. 任何用户 / LLM 影响的 URL → safeFetch,绝不使用裸 fetch;带凭据的请求,URL 不能来自用户或模型(§3)。
  2. 危险操作正确声明 risk:不可逆 / 外泄 / 改系统 → risk:'dangerous' 或 visibility:'restricted' 加 confirm; 删库级 → confirm:'always'(§2)。漏标会让被注入的 LLM 直接放行。
  3. 执行不可信代码 → 取 code-sandbox 服务,available 为假就 fail-closed,不要裸跑(§4)。
  4. resolveLocalPath 不是沙箱边界,只当工作目录起点使用(§5)。
  5. readExternalFile 只传入可信来源的路径,永不传入用户 / LLM 提供的字符串(§6)。
  6. 抓取类工具的外部内容回灌 LLM 时套 wrapUntrustedContent:网页正文 / 搜索结果 / HTTP 响应 / MCP 返回都是注入入口,标注「数据非命令」是纵深防御的一环,写法见 plugins/plugin-tools「抓取外部内容的工具」一节。
  7. 要求「真人当面发起」的工具正向检查 ToolCallContext.inbound:缺省(workflow、mcp-server 等)与 inbound.source 非空(定时、闲置、宿主通知等内部注入)都当作不满足,不要只看参与者:定时任务注入的回合的参与者是创建者。
  8. 注入入站消息设 source,不设 ownerApproved:注入的文字不执行指令,要到点执行指令经定时任务建任务(§2「注入的文字不执行指令」)。
  9. 记住威胁模型:敌人是聊天中的陌生人和被注入的 LLM,不是已经取得 shell 的人(§1)。

交叉链接 ​

WebUI 的插件前端 ​

插件可以给页面自带前端(见 api-webui 的「插件前端」):插件包里的一份静态网页,由 webui-server 托管,在宿主画的 iframe 里运行,只放在插件页面与会话面板。插件的服务端代码在进程内运行,本来就有全部权限,这道隔离防的不是恶意插件本身,而是三件事:插件前端渲染不受信的内容(群成员的昵称与消息、网页标题)时出现注入,注入的东西拿到 owner 的登录状态与全部管理接口,或把框架换成仿冒宿主的页面;插件前端的写法弄乱或拖垮宿主界面;插件前端借宿主转发去做它没声明过的事。

  • 不同源: iframe 的 sandbox 只给 allow-scripts,框架文件的响应头 CSP 里再带一次 sandbox,框架是不透明来源:读不到 cookie 与浏览器存储,碰不到宿主页面。别人把框架地址当顶层页面直接打开,照样是不透明来源。框架文件经 /frames/<token>/ 托管,token 每次登记随机生成、只出现在要登录的页面列表里,托管不认 cookie。
  • 脚本与联网: 插件前端可以联网,脚本只认插件自己打包的文件。框架 CSP 除脚本外按类型放开、不按来源限:外站的样式、字体、图片、音视频与数据请求都放行。只留两道:脚本只认这一页自己的目录与宿主的公用目录,不许内联与 eval,注入的标记执行不了脚本;框架里不能再嵌别的网页(frame-src、object-src 都是 'none')。
  • 宿主接口不认框架的请求: 框架是不透明来源,发给宿主的请求不带登录状态(cookie 是 SameSite=Strict),取数回 401;改状态的请求与 WebSocket 握手的 Origin 是 null,按来源回 403。
  • 只经宿主代办: 框架与宿主之间只有握手后交给框架的专用端口。实例、页面与会话面板的会话由宿主按挂载时的值填,框架只能调这一页声明过的数据源与动作,走与声明式页面同一道服务端闸门;destructive 动作与外链都要用户在宿主画的对话框里亲手确认(同一时刻只一个,打开后 500 毫秒内不接受确认,连续取消 3 次卸载框架)。读会话、停回合这类宿主数据能力要在声明里申请,词表封闭,审批应答、发消息、改设置、文件与媒体都不给。
  • 外围由宿主画: 框架上面一行写「来自 <插件显示名>」,标题栏、标签栏、占位、确认框与 Toast 都由宿主画,框架画出的仿冒界面总在这一行下面。

残余:

  • 框架能联网,读得到的数据都可能被带出去。能读到数据又能存心外传的只有插件前端自己的代码,而插件的服务端代码本来就能联网,所以这一条不增加对恶意插件的暴露。
  • 作者写法出错、把别人给的文字当 HTML 插入时,注入的标记跑不了脚本,但能借样式把页面上的东西带到外站:属性选择器配外站的背景图,能把链接地址、输入框的值逐字送出去;插一张外站的图,能得知 owner 打开页面的时间与 IP。作者指南要求显示别人给的文字一律用只显示纯文字的写法。
  • 沙箱挡不住框架让自己跳到别的地址,注入的 <meta http-equiv="refresh"> 不需要脚本。框架离开时宿主立即卸载并记告警(助手在 pagehide 时发 leaving,第二次 load 兜底),但跳到不返回页面的地址(例如回 204)时宿主察觉不到,新页面开始显示到卸载之间也有一小段。
  • 框架里的死循环可能与宿主同一个渲染线程,卡住整个界面,宿主无法打断,只能停用插件。
  • 部署前提:经反向代理访问时要转原始的 Host;以 IPv6 字面地址访问时插件前端不可用。以后的桌面封装要保证原生桥只给顶层文档、按地址授予的原生能力匹配不到 /frames/、子框架开新窗口与跳转由封装拒绝。

文档对应 dev@c4e046e41,core 0.21.0,构建于 2026-11-02
基于 MIT 许可发布