Minibar 能力矩阵与权限模型
状态 v0.3 · 面向插件作者与审核员 · 英文版 capabilities.en.md · 接口细节见 03-plugin-api.md
Minibar 是一个基座:它向 macOS 申请得到的每一项能力,都应该以可声明、可授权、可撤销的方式开放给插件;反过来,插件没有声明的能力,基座绝不会替它向系统申请。本文是这两句话的完整展开——能力清单、每项能力对应的权限与系统授权、基座如何落实最小权限、哪些能力明确不做、以及后续计划。
1. 五条原则
| # | 原则 | 落实方式 |
|---|---|---|
| P1 | 声明即边界 | 插件能用什么,全部写在 manifest.json:permissions、allowedHosts、allowedSchemes。没声明的调用一律 permission_denied / host_not_allowed / invalid_argument,运行时与 App 进程各检查一次(双重强制)。 |
| P2 | 最小权限,懒触发 | 基座启动、安装插件、引导流程都不会向 macOS 申请任何系统权限。系统授权弹窗只在"已安装 + 用户已授予该插件 + 插件第一次真正调用"时出现。没有任何插件声明某项权限,这台 Mac 上就永远不会出现对应的系统弹窗。entitlements 是静态最小集,由 CI 测试锁死(§5)。 |
| P3 | 副作用只响应用户点击 | 打开链接/App、写剪贴板、释放内存、运行快捷指令、保持唤醒——所有"对 Mac 做点什么"的调用只能在 action(用户点了按钮)里执行,render 里调用抛 not_in_action。杜绝"每次刷新偷偷做事"。 |
| P4 | 逐项、可撤销、即时生效 | 安装弹窗逐条解释每项权限;设置页可单独关闭任意一项;撤销后插件下一次调用立即被拒,并释放它持有的资源(如唤醒断言)。 |
| P5 | 可审计 | 源码可在安装前查看;安装弹窗列出能访问的域名与 URL scheme;每次能力调用写入插件日志;诊断导出不含插件密钥。 |
2. 能力矩阵
状态:✅ 已实现(v0.3)· 🗓 计划(标注里程碑)· ⛔ 不做(见 §4)
| 能力 | ctx 接口 | 权限 | 系统授权 / entitlement | 阶段 | 状态 |
|---|---|---|---|---|---|
| 时间、语言、宿主版本 | ctx.now() ctx.locale ctx.host | 无 | — | 任意 | ✅ |
| 日志 | ctx.log() console.* | 无 | — | 任意 | ✅ |
| 持久存储(1 MB) | ctx.storage.* | 无 | 沙箱容器文件 | 任意 | ✅ |
用户设置(含 secret) | ctx.settings.* | 无 | — | 任意 | ✅ |
| 系统指标:CPU、内存、磁盘、负载、网络速率、电池 | ctx.system() | 无 | — | 任意 | ✅ |
| 硬件信息:机型、芯片、核心布局、内存总量 | ctx.system().hardware | 无 | — | 任意 | ✅ |
| 内存压力、交换空间 | ctx.system().memory.pressure .swapUsed | 无 | — | 任意 | ✅ |
| 已授予的权限 | ctx.permissions.granted(name) .list() | 无 | — | 任意 | ✅ |
| 哈希、HMAC、Base64、UUID | ctx.crypto.* | 无 | — | 任意 | ✅ |
| HTTPS 请求(白名单域名) | ctx.http.* | network | network.client | 任意 | ✅ |
| 打开 http / https / mailto 链接 | ctx.openURL()、button.url | 无 | — | action | ✅ |
打开其他 App 的 URL scheme(things:、obsidian:…) | ctx.openURL() | apps + allowedSchemes | — | action | ✅ |
| 打开 / 激活某个 App | ctx.apps.open(bundleID) | apps | — | action | ✅ |
| 正在运行的 App 列表、前台 App | ctx.apps.running() | apps | — | 任意 | ✅ |
| 通知 | ctx.notify() | notifications | UNUserNotificationCenter 授权(首次发送时) | 任意(每分钟 1 条) | ✅ |
| 读剪贴板 | ctx.clipboard() / .read() | clipboard | 每次 App 会话首次读取再确认 | 任意 | ✅ |
| 写剪贴板 | ctx.clipboard.write(text) | clipboard | — | action | ✅ |
| 日历(读 + 新建日程) | ctx.calendar.upcoming() ctx.calendar.add() | calendar | TCC 日历(首次调用时)· personal-information.calendars | 读任意;add 仅 action | ✅ |
| 提醒事项(读 + 新建 + 完成) | ctx.reminders.incomplete() .add() .complete() | reminders | TCC 提醒事项(首次调用时)· 同上 entitlement | 读任意;add / complete 仅 action | ✅ |
| 定位(约 1 km) | ctx.location() | location | TCC 定位(首次调用时)· personal-information.location | 任意 | ✅ |
| 释放内存 | ctx.freeMemory() | system | — | action | ✅ |
| 运行用户的快捷指令 | ctx.runShortcut(name, input?) | system | — | action | ✅ |
| 阻止系统休眠(保持唤醒) | ctx.keepAwake(enabled) | system | IOPMAssertion | action | ✅ |
| 自定义 Web 卡片(React 等任意框架) | web 节点 | 无 | WebKit(仅插件包内文件,无网络) | 任意 | ✅ |
| 用户选择的文件/文件夹(只读) | ctx.files.pick() .list() .entries() .read() .forget() | files | NSOpenPanel(用户亲自选)+ security-scoped bookmark · files.user-selected.read-only + files.bookmarks.app-scope | pick / forget 仅 action | ✅ |
| 联系人(生日、即将到来) | ctx.contacts.* | contacts | TCC 通讯录 · personal-information.addressbook | 任意 | ⏸ 暂不做:需求弱、敏感度最高,且多一个 entitlement 要向审核解释;生日提醒可由用户在日历/提醒事项里订阅后经 calendar / reminders 读到 |
| 把插件动作暴露给快捷指令 / Siri | App Intents | — | — | — | 🗓 M5(非权限项,不影响最小权限集) |
| 音量 / 输出设备 | ctx.audio.* | system | CoreAudio | action | 🗓 M5(评估) |
| Shell、脚本、任意文件、AppleScript、屏幕、键鼠、摄像头、麦克风、网络监听 | — | — | — | — | ⛔ |
3. 各权限详解
network
- 只能访问
allowedHosts中的域名(含子域),只能 https(localhost 例外),单次 ≤ 15 秒、响应 ≤ 5 MB,一次 render 累计等待 ≤ 30 秒。 "*"允许任意域名,安装弹窗红字警示。市场审核会特别关注。- 基座 entitlement:
com.apple.security.network.client(无 server)。
clipboard
- 读:每次 App 会话首次读取前,卡片上再确认一次(PRD F-45),之后本会话内不再打扰;撤销权限立即失效。
- 写:只能在 action 中,≤ 100 KB 文本。写不需要会话确认(不泄露任何东西)。
notifications
- 每插件每 60 秒 1 条;首次发送时才请求系统授权;系统层关闭则静默丢弃并记日志。
calendar / reminders
- 读在任意阶段;macOS 14+ 请求 full access(EventKit 只提供这一档可读权限)。首次调用触发系统弹窗,弹窗期间插件得到
os_access_pending,用户回答后基座自动重渲染。 - 写只在 action 中(用户点了按钮):
calendar.add()新建日程、reminders.add()新建提醒、reminders.complete(id)完成/取消完成一条提醒。只能写入允许修改的日历/列表,标题 ≤ 200 字、备注 ≤ 2000 字、日程时长 ≤ 1 年;没有删除、没有批量修改、没有对其他人日程的改动。内置"提醒事项"插件用complete让用户在菜单栏里直接打勾。
location
- 1 km 精度,15 分钟缓存,反向地理编码得到城市名。首次调用触发系统弹窗,流程同上。
system
freeMemory():基座申请并触碰最多一半物理内存、最长 3 秒,迫使内核回收缓存与非活跃页,随后释放;空闲页过低即停。返回前后空闲字节。runShortcut(name, input?):经shortcuts://run-shortcut运行用户自己创建的快捷指令。这是插件触达系统其他部分的通用通道——打开 App、控制 HomeKit、调用任意服务——边界由用户在快捷指令里决定。keepAwake(enabled):IOPMAssertionCreateWithName阻止系统空闲休眠;每插件一个断言;插件被停用、撤权、卸载或 App 退出时自动释放。- 三者都只能在 action 中调用。
apps
apps.running():常规(Dock 可见)App 的名称、bundle ID、是否前台、是否隐藏。不含窗口标题、进程参数。apps.open(bundleID):打开或激活已安装的 App;未安装返回unavailable。allowedSchemes:manifest 里声明的 URL scheme(如things、obsidian、raycast),ctx.openURL("things:///add?title=…")才会放行;未声明的 scheme 一律拒绝。http/https/mailto永远允许,file/javascript/data永远拒绝。
files
- 插件拿不到任何路径的"读取权",只能拿到用户在系统文件对话框里亲手选的文件或文件夹:
ctx.files.pick({ folders, multiple, types, message })只能在 action 中调用,立即返回,对话框由基座弹出,标题写明是哪个插件在要;用户选完后基座保存 security-scoped bookmark 并重新渲染该插件。 - 之后任意阶段可用:
list()列出已选项(含available,被移动/删除的项标为不可用)、entries(id)列出已选文件夹的直接子项(默认跳过隐藏文件,≤ 500 项)、read(id, { encoding, maxBytes })读取(≤ 1 MB,超出标记truncated)。子项以"<id>/<相对路径>"寻址,..、绝对路径、穿出文件夹的符号链接一律拒绝。 - 读取全部发生在 App 进程的沙箱作用域内,XPC 运行时进程从不接触文件系统;内容以 JSON 字符串交给插件。
- 撤销:插件可
forget(id);用户可在"设置 › 插件 › 此插件可读取的文件"逐项或全部移除;关闭files权限即清空全部;卸载随记录一并删除。每插件最多 50 项,多选时新者保留。 - 不提供:写入、创建、删除、监视目录、遍历用户目录(用户没选的任何东西都不可见)。
4. 明确不做的能力
| 能力 | 原因 |
|---|---|
| Shell / 子进程 / 脚本执行 | 沙箱下不可行;一个 shell 插件就能毁掉整个市场的信任 |
| 任意文件读写、用户目录遍历 | 沙箱 + 审核;只有用户在对话框里亲手选的文件以 files 权限只读开放 |
| AppleScript / Apple Events | 需要 temporary-exception entitlement,审核高风险;快捷指令是 Apple 认可的自动化通道 |
| 屏幕录制、辅助功能(键鼠控制)、输入监听 | 隐私与审核;菜单栏信息面板不需要 |
| 摄像头、麦克风 | 同上 |
| 网络监听 / 本地服务器 | 插件没有后台执行,也不该让别的进程连进来 |
| 读其他 App 的数据、Keychain | 沙箱隔离;插件密钥只存在自己的 secret 设置中 |
| 原生代码、动态库 | 2.5.2 / 4.7;插件永远是 JavaScript + 声明式 UI(或 Web 卡片) |
5. 基座如何落实最小权限
5.1 entitlements(静态最小集,CI 锁定)
| 文件 | 键 | 对应能力 |
|---|---|---|
Minibar.entitlements | app-sandbox | 全部 |
network.client | registry、插件更新;network 权限的请求实际在 XPC 服务里发出 | |
personal-information.calendars | calendar / reminders | |
personal-information.location | location | |
files.user-selected.read-only | 本地安装、链接文件夹、files 权限的文件对话框 | |
files.bookmarks.app-scope | 跨启动记住链接文件夹与 files 权限选中的项 | |
MinibarRuntime.entitlements | app-sandbox、network.client、cs.allow-jit | 插件代码与 http 的执行环境 |
Tests/MinibarCoreTests/EntitlementsTests.swift 断言这两个文件的键恰好是上面这些;新增能力必须同时改测试和本表。
5.2 系统授权只在首次调用时触发
| 系统权限 | 触发点 | 绝不触发的时机 |
|---|---|---|
| 日历 / 提醒事项 | 已授权插件第一次调用 ctx.calendar / ctx.reminders | 启动、安装、引导、授予权限的那一刻 |
| 定位 | 第一次调用 ctx.location() | 同上(CLLocationManager 创建不弹窗) |
| 通知 | 第一次 ctx.notify() | 同上 |
引导流程只写入插件级授权,不碰系统弹窗;引导结束页明确告知"插件首次使用时 macOS 可能询问一次"。
5.3 撤销与清理
撤销某项权限后:运行时与 App 进程的检查立即生效(无需重启);system 被撤销或插件停用/卸载时释放唤醒断言;clipboard 撤销后会话确认同时清除。卸载插件删除其存储与设置。
5.4 审核员视角
- 插件能访问的全部接口就是本文 §2,没有隐藏通道;
__host只接受字符串,权限检查在桥接层与 App 进程各一次。 - 任何副作用都要用户点击;任何系统弹窗都要用户先给插件授权。
6. 插件作者指南
- 只声明用得到的权限,并在
description里说清楚用途——安装弹窗会原样展示。 - 优雅降级:没授权时也要能渲染。用
ctx.permissions.granted('location')决定是否走定位分支;catchpermission_denied并给一个$allow:<permission>按钮,用户一点即授予。 - 系统弹窗期间(
os_access_pending)不要重试死循环,基座会在用户回答后自动重渲染。 - 副作用放 action:按钮 → action →
ctx.openURL / apps.open / clipboard.write / freeMemory / runShortcut / keepAwake。 - URL scheme 必须写进
allowedSchemes,并申请apps。 - 不要把密钥写进代码:用
secret类设置,ctx.settings.get()读取;基座不会把它写进日志。
7. 路线图
| 里程碑 | 能力 | 备注 |
|---|---|---|
| M3(本轮) | system、apps、clipboard.write、keepAwake、crypto、permissions、Web 卡片 | 已实现,待真机验收 |
| M4 | ✅ files(用户选择的文件/文件夹,security-scoped bookmark);✅ reminders.add / reminders.complete / calendar.add;⏸ contacts(暂不做,见 §2) | 每项都走本文的声明 → 懒授权 → action 流程 |
| M5 | App Intents(把插件动作暴露给快捷指令 / Siri);audio(音量、输出设备);插件间消息(ctx.bus) | 评估需求后再定 |