安全模型 — 威胁模型与插件作者的责任边界
面向:编写和维护 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等既有服务(服务模型的选择规则如此设计),下游惰性getService会立即改路由;也可以直接 monkey-patch 任何护栏。这是特性不是漏洞 —— 「万物皆插件」的前提就是任何实现可被替换,而装插件是 owner 级操作。 框架里各种「护栏」(禁卸内核、降级守卫、串行闸)防的都是误操作,不是恶意插件。 真正的防线在装之前:市场的依赖图端点会列出目标提供 / 需要哪些服务,装前可见。
因此,你的插件真正要防的是聊天中的陌生人和被注入的 LLM,而不是已经取得 shell 的攻击者。
2. 权限两轴(速览 + 链接)
权限裁决拆成两条互相正交的轴:
轴 A · 授权(谁有资格):把触发者的等级和操作的最低等级比大小。
resolveAccess的优先级如下,首个命中者胜出:deniedCapabilities(全局硬禁 glob,压过 owner) > owner(∞) > 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 黑名单,而是「这台机器上谁都不许做」的系统级断路器,应谨慎使用。
- 身份映射到整数等级:默认
轴 B · 确认(是不是你本人此刻要做):HITL(human-in-the-loop)意图确认,与等级无关。 关键点是 owner 也受确认约束——这道关卡专门为「owner 会话被提示注入借权、静默调用高危操作」的场景设计。
confirm: 'always'永不可跳过(最高危,每次都需要人工确认;cron 这类无人确认的来源直接拒绝)。confirm: 'session'可在三种情况下跳过:来自系统或受信源时(skipConfirm,例如 scheduler 无人可确认), 或触发者是 owner 本人且 auto 模式已激活时(shouldSkipConfirm)。
risk 是一个便捷声明,一次为两轴设定默认值:dangerous 会展开成 visibility:'restricted'(抬高轴 A 门槛)加上 confirm:'session'(轴 B 需确认), 这套默认值由 RISK_DEFAULTS 定义。
两轴的完整机制(临时放行
requestAccess、会话授予、auto 模式、WebUI 权限页、users.json 持久化、 session-confirm 协调)见 权限系统文档 与 forward-refdocs/services/authority.md。这里只给出安全视角的要点。
插件作者怎么标操作风险(provider 侧)
裁决发生在 commands / tools 的执行边界。你不需要手动调用 authorize——只要在注册操作时 把风险声明正确,框架就会自动挂上权限闸。
工具注册(与内置 http_download 工具的写法一致):
tools.register({
definition: { type: 'function', function: { name: 'my_write_tool', /* ... */ } },
// 写操作:受限 + 每次确认。防止被注入的 LLM 静默、越权地写进 storage。
visibility: 'restricted',
confirm: 'session',
handler: async args => { /* ... */ },
});命令注册(用 risk 糖一次为两轴设默认):
ctx.command('profile.self.clear', '【慎用】清空 Aalis 自档案', { 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 作为群管的价值。滥用防线不在能力声明层,而在checkAdminPermission(bot 需具备对应群角色、目标不高于 bot)+ Aalis 自身的判断 (见人设卡:不随便听人指令撤/禁/踢,自行评判是否属实、必要时反坐提议者)。 - 给每次群操作上
confirm会让「见到不好的事自动处置」这条核心能力失效——处置的即时性正是它的意义。
重新评估的触发条件:把 bot 交给完全不受信的开放群、或发现提示注入能稳定绕过 Aalis 的 自主判断驱动破坏性群操作时——那时应给破坏性群管工具上 confirm,或引入「举报→Aalis 判断」 与「直接命令执行」的分流。
注:
http_request(plugin-tool-system的system组)也未声明 risk,解析为 public。 它不在 onebot 的 enabledGroups 里(onebot 只开 search / onebot-* / browser / math / session-* / scheduler / user-relation),故当前 bot 部署下不可达。与同文件http_download(restricted + confirm)的不对称是有意的:后者上闸因为写 storage,与出网无关。这条判据的前提是分组闸:带分组的工具只在平台档 / 会话配置列出该组(或
'*')时才暴露;npm create aalis只给已选装的cli/webui写['*'],多人平台一律不代开。这道闸此前只在列举面生效:
ToolRegistry.execute按名直调不校验分组,被提示注入的模型 叫出一个本回合没下发给它的名字即可执行(已实测复现)。现已在执行面补齐同一判据—— 调用方传了enabledGroups时不命中即拒。上面「不在 enabledGroups 里故不可达」的判据 因此才真正成立;引用它作为维持 public 的理由时,前提是执行面的闸在。 分组闸有一个旁路:多人平台开了session-delegate组时,群成员可以用delegate_to_session把任务派进 owner 平台(webui / cli)的会话,目标会话按它自己的工具集推理,授权身份仍是发起者(actor)。 此时 owner 平台开放的 public 带组工具(包括http_request)对发起者可达。
2026-08-23 复核补充(逐条对码核验后维持不声明,勿再报):
http_request/recent_messages/list_known_sessions:曾统一抬档,经用户裁定全部回退—— 读类档位牺牲的是爬网页与跨群感知这类核心体验,且 http_request 有分组闸兜底(见上)。delegate_to_session:维持 public,但按 schema-message 的 actor 契约回填授权身份—— 权限跟发起者走(owner 委派出去才有 owner 能力,匿名委派只有等级 0),比抬档位精确。- 定档纪律:引用其他工具档位作判据前先读其注释的真实理由(
browser_navigate的 sensitive 源于共享页面池带登录态、file_read源于本机隐私,均与「出网」无关)。
与之相对,plugin-scheduler 的建/删/暂停任务是上了 dangerous + confirm 的——因为建一条 cron 等于让 LLM 获得持久执行面,那已经越过"只影响自己账号"的边界。
3. safeFetch:默认的 SSRF 安全出口
任何由用户 / LLM / 入站消息影响到的 URL 的远程请求,都必须走 @aalis/util-network-guard 的 safeFetch, 不要直接使用裸 fetch。
safeFetch 的机制是逐跳 redirect:'manual' 加上每一跳都重新执行 assertSafeUrl, 用于防御 SSRF:
- 协议白名单:只允许
http:/https:,其余(file:、gopher:等)直接拒绝。 - 私网 / 回环 / 链路本地 / 元数据段封锁(
isPrivateAddress): 覆盖10.0.0.0/8、127.0.0.0/8、0.0.0.0/8、169.254.0.0/16(含 AWS / 云元数据169.254.169.254)、172.16–31、192.168、组播 / 保留段,以及 IPv6 的::1、::、fe80:、fc、fd、::ffff:映射。 域名同样会被检查:dns.lookup(all)解析出的每条 A/AAAA 记录只要命中私网即拒, 以此堵住 DNS rebinding。localhost、*.localhost、*.local主机名会被直接拦截。 - 逐跳重定向重校验:30x 响应的
Location解析成绝对 URL 后再过一遍assertSafeUrl, 杜绝「初始 host 受信,但 302 跳到http://169.254.169.254/内网」这类经典绕过。跳数上限为 5(MAX_REDIRECTS)。 - 进程级网络策略(
setNetworkPolicy):owner 经 core 的network配置可以 关闭私网拦截(blockPrivate:false,用于本地自动化)、追加denyCidrs、限定allowedPorts。 这项策略在启动时由plugin-authority注入一次。
消费者侧用法很简单(一行替换 fetch),全仓已有十几处复用——onebot 附件下载、media、ASR、ollama、 office、webui 图片代理、http 工具:
import { safeFetch } from '@aalis/util-network-guard';
// 用户/LLM 给的 url:直接当 fetch 用,SSRF 校验已内置
const res = await safeFetch(url, { signal: AbortSignal.timeout(15_000) });若你还需要校验单个主机名(非 fetch 场景,例如流式代理自己管理连接),用
assertSafeHost(hostname); 只想校验 URL 并拿回URL对象,用assertSafeUrl(rawUrl)。
跨域重定向的凭证泄漏
safeFetch 的每一跳都会把同一个 init 原样重发。这意味着:如果你在 init.headers 里带了 Authorization 或 cookie 等凭证,而上游返回 302 跳转到另一个 origin, 你的凭证会被原样发送到那个新 origin。safeFetch 只保证跳转目标不是内网地址, 并不保证跳转目标有资格看到你的 token。
插件作者的对策(可参考图片代理的做法):
- 对用户 / LLM 影响的 URL 调用
safeFetch时不要带任何凭证 / cookie / 用户 referer—— 图片代理就显式只给一个伪 UA、不带 cookie。 - 若确实要带凭证访问你自己已知的固定 API,那个 URL 就不应来自用户输入; 或者自行禁用重定向、比对最终 origin。
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 真能用」两点。
如果你的插件要执行不可信代码,请用
useCodeSandbox(ctx)取服务,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 用户权限。
请把
resolveLocalPath的结果当作「工作目录起点」使用,而不是「沙箱边界」。 storage URI 文法、保留 scheme(http/https/file不是 storage URI)见 forward-refdocs/concepts/storage-uri-grammar.md与docs/services/storage.md。
6. readExternalFile:confused-deputy 读任意路径
ProcessService.readExternalFile(path) 会直接读取 OS 上的任意本地路径(绝对路径或 file://), 完全绕过 storage 的 root 沙箱。它本质上就是 fs.readFile 再加一层 file:// 剥壳。
它存在的理由是确实有合法场景需要读取「外部推来的本地路径」,例如 OneBot daemon 推送的附件路径、 ASR / ollama 探测本地文件等。现有消费者包括 onebot 适配器、asr-openai、media 等插件。
这是一个 confused-deputy(混淆代理)面:daemon 进程是受信的,但「要读哪个 path」这个参数 可能源自不可信输入。如果路径来自聊天用户或 LLM,攻击者就可以诱导你读取 /etc/passwd、 ~/.ssh/id_rsa、data:/users.json 等文件。契约注释对此写得很直白:「调用方自行保证安全性」—— 框架在这里不替你挡。
插件作者的对策:
- 绝不把用户 / LLM 给的字符串原样传给
readExternalFile。 - 只在「路径由你信任的 daemon / 协议带来」时使用它(例如 onebot 上报里的
file字段)。 - 用户给的内容路径,优先走 storage(受 root 约束);确需读取外部文件时,先做白名单校验目录前缀。
7. 给插件作者的安全清单
- 任何用户 / LLM 影响的 URL →
safeFetch,绝不使用裸fetch;带凭证时警惕跨域重定向(§3)。 - 危险操作正确声明 risk:不可逆 / 外泄 / 改系统 →
risk:'dangerous'或visibility:'restricted'加confirm; 删库级 →confirm:'always'(§2)。漏标会让被注入的 LLM 直接放行。 - 执行不可信代码 → 取 code-sandbox 服务,
available为假就 fail-closed,不要裸跑(§4)。 resolveLocalPath不是沙箱边界,只当工作目录起点使用(§5)。readExternalFile只传入可信来源的路径,永不传入用户 / LLM 提供的字符串(§6)。- 抓取类工具的外部内容回灌 LLM 时套
wrapUntrustedContent:网页正文 / 搜索结果 / HTTP 响应 / MCP 返回都是注入入口,标注「数据非命令」是纵深防御的一环,写法见plugins/plugin-tools「抓取外部内容的工具」一节。 - 记住威胁模型:敌人是聊天中的陌生人和被注入的 LLM,不是已经取得 shell 的人(§1)。
交叉链接
- 兄弟概念:权限两轴 / authority · forward-ref storage URI 文法 · forward-ref DI 服务模型
- forward-ref 服务文档:
services/authority(裁决服务全量 API)·services/storage·services/process - 相关插件文档:
plugin-tool-code-runner·plugin-code-sandbox-os·plugin-authority - 多用户 / 云端:搁置(未实现)