04. 插件安全
翻译说明: 本页是与 英文源规格 一一对应的机器辅助翻译。代码、协议字段和标识符保持原文;如翻译与英文源事实有歧义,以英文版本为准。
1. 威胁模型
插件可能来自:
- 用户自行开发
- 同事分享
- 未来市场中的第三方
主要风险: 1.恶意文件read/write 2、恶意命令执行 3.窃取API密钥/会话内容 4. 劫持代理工具 5. 通过用户界面进行网络钓鱼 6. 消耗用户的模型额度,或把对话发给另一个模型(agent.complete / session.read)
2. 默认拒绝原则
- 未声明的权限=不可用
- 禁用插件 = 代码未加载
- 未经确认的高风险行动=未执行
- 主机 API 不在允许名单上 = 不存在
pi.browser.cdp不在 CDP 白名单上的方法 =PERMISSION_DENIED(无 cookies、storage、Target 或 Fetch;无 DevTools websocket)
3. 隔离策略
必须
1.插件UI与宿主UI DOM隔离 2. 插件不能直接请求主机模块 3.秘密商店不对插件开放。宿主代发的补全(agent.complete)在 Electron main 解析凭据,从不把密钥、刷新令牌或 ModelAuth 交给插件进程 4.插件私有数据目录与主机核心库分离 5. session.getLlmContext 的会话转录只是进行中工具会话的有界投影(D336 / D019)
剪贴板历史由 Electron 主进程持有,仅保存在内存中,不会写入插件数据目录或主机 数据库。主机只记录明确的剪贴板写入和用户主动在 Composer 中粘贴的内容,不会在后台 轮询系统剪贴板。插件必须通过现有的 clipboard.read 权限读取;每次 getHistory 调用都会 记录审计及返回条目数。应用退出时历史会清空,保留上限也限制了隐私暴露范围。
目标
1.插件main在单独的进程中运行 2. 碰撞隔离 3.资源限制(后来:CPU/memory/timeout)
3. 1 贡献主题CSS
主题贡献 (ui.theme) 是插件创作内容的一种情况 在主机渲染器内部运行,因此它会在主进程中穿过消毒剂 在发送到 UI 之前:
- 只检查浏览器实际生效的 CSS:先按等长空白遮蔽注释体与字符串字面量 (每个被遮蔽字符对应一个空格,偏移仍指向原文),
url(...)参数按原样保留 并按目标判定。因此仅在注释或字符串里提到被禁关键字的样式表会被接受 - 拒绝:
@import、任何不是data:的url()目标 URI、url(解析器无法解析javascript:、expression(和标记序列 (<style、</style、<!--);空纸也会被拒绝 - 每个文件上限为 256KB,每个插件 8 个主题
- 主题可声明
assets(绝对路径、扩展名白名单、总量上限 4MB)。命中的url()会被改写为plugin-asset://<pluginId>/<path>,由宿主的处理器提供;该处理器 只按已加载插件自己登记的清单解析,只读、带nosniff,并在插件卸载时一并撤销。pi.themes.upsert也能在运行时登记同样的路径。未登记的引用仍被拒绝, 原始路径不会到达渲染器 contributes.windowAppearance(#rrggbb/#rrggbbaa)需要ui.window.appearance,且只在该插件的某个主题被选中时生效;离开该主题即恢复 宿主背景,因为颜色由实时主题目录推导而非记忆。macOS 保持vibrancy, 永不下发颜色- CSS 在加载时从磁盘读取并通过 IPC 整体交付;的 渲染器将其注入到附加在后面的单个专用
<style>元素中 应用程序自己的样式表,因此它可以覆盖令牌但从不注入标记 - 选择主题是一个设置值(
plugin:<pluginId>:<themeId>);如果 如果插件被禁用或卸载,设置将回退到system
CSS 无法脚本化,但它可能会产生误导:主题仍然是第三方代码塑造 用户看到的内容,这就是为什么它是声明的、可撤销的权限。
4. 权限授予用户体验
在 install/load 时间,显示:
- 权限列表
- 风险描述
- 开发者信息
- 源路径
用户操作:
- 接受并启用
- 取消
首次使用高风险的 API 可能会再次确认。
5. 数据隔离
插件可以访问:
- 他们自己的设置
- 他们自己的数据路径
插件无法访问:
- 其他插件的数据
- 主机秘密
- 主机的完整会话数据库(除非将来存在受控的 API)
5. 1 插件间消息总线
总线是两个插件之间的唯一通道,并且故意狭窄:
- 双方在清单中声明其流量 -
bus.publish列出了具体的流量 主题,bus.subscribe列出了模式 - 经纪人拒绝任何内容 即使获得许可也未声明 - 路由完全存在于主机中;订户永远不会知道还有谁 订阅,发布者被排除在自己的扇出之外
- 消息仅携带
topic、from、payload和主机分配的at - 上限:每个有效负载 64KB,每个插件 16 个订阅,每个滚动 100 个发布 10秒窗口;超上限调用失败并显示
LIMIT_EXCEEDED/RATE_LIMITED与主题一起审核 - 有效负载是数据,而不是能力:接收消息并不授予任何功能 订户还没有
将主题视为应用程序内的公共主题:任何可以声明匹配的插件 模式并按住 bus.subscribe 就会看到它。不要在公交车上泄露秘密。
6. 路径安全
fs.read / fs.write / fs.delete 说明插件能不能碰文件;manifest.fs 说明能碰哪些(参见 02-plugin-manifest-schema.md §5.2 与 ADR 0088)。每次 pi.fs.* 调用按固定顺序过四道门, 后面的门只能拒绝:
- 权限 —— 既声明又授予。运行时取两者的交集,所以用户撤销的权限 会真的失效,即使清单里还在要
- containment —— 对 root 和目标都做
realpath,因此工作区里一个指向~/.ssh的软链会在这里失败,而不是通过字符串比较。正在创建的路径 按最近的已存在祖先解析,所以新建文件不会和逃逸无法区分。绝对路径 和..一律拒绝;仅仅是不存在的路径报NOT_FOUND,不算逃逸 - deny-list,它压过一切 root、scope 和授权:
- 凭证:
.env*、.npmrc、.netrc、.pypirc、.git-credentials、id_rsa*及同类、*.pem、*.p12、*.pfx、*.keystore - 任意深度的目录:
.git、.ssh、.aws、.gnupg、.kube、.docker - 主机自己的数据目录,里面存着 provider key 和会话库
- root 本身(对删除而言)
- 凭证:
- 声明的 scope,否则走原生确认(§6.2)
pi.fs.glob 遵守同一套规则 —— 名字也是一种读取,所以被拒的路径和保留目录树 不会出现在结果里,匹配结果会按读取范围过滤,而 node_modules / .git / .venv / __pycache__ 永不遍历。
6.1 删除
删除是唯一一种用户无法靠重跑插件补回来的文件操作,因此有四重约束:
- 两档。 带
own: true时主机在插件数据目录里维护一份写入台账 (路径 + mtime),允许插件删除自己写过的东西,不需要 scope、不需要弹窗。 如果文件的 mtime 已经晚于记录值,说明用户之后改过,它就不再属于插件。 删别的东西需要声明scope。 - 系统回收站。 删除走
shell.trashItem,绝不走rm,因此某道门判断错了 代价是用户去还原一下,而不是文件没了。主机不为此复制用户的任何数据。 - 永不递归。 非空目录直接拒绝,而不是清空。
- 速率刹车。 每个插件每滚动 60 秒 50 次删除 —— 因为
recursive: false只能约束单次调用,约束不了glob加一个循环。超过之后问用户一次, 理由写的是速率而不是路径。
6.2 运行时同意
清单没有覆盖的访问会走到原生 dialog.showMessageBox: 拒绝 / 允许一次 / 本会话允许。会话授权覆盖所在目录, 存在内存里,随进程消失;什么都不持久化,而速率刹车的弹窗根本不提供 会话选项。没有同意服务的主机一律拒绝 —— 问不了就绝不能假设「是」。 拒绝和授权都会记入审计。
6.3 用户选定的 root
pi.fs.requestDirectory() 打开原生目录选择器;在返回的目录里插件不需要 清单 scope,因为用户刚亲手指了它。containment 和 deny-list 在那里依然生效。 句柄只存在于内存中,随进程消失,所以插件拥有无限的可达范围和零常驻权力 —— 和浏览器 File System Access API 的模型一样。
7. Agent 安全性
- 插件工具名称采用命名空间,以避免使用冻结强制前缀
plugin_<pluginIdSafe>_<toolName>(D015) 发生冲突 - 工具执行超时
- 用户可以一键禁用工具
- 提示注入 API 默认情况下是高风险的,需要显式许可
7. 1 到达代理的技能和 MCP 工具
两个表面都可以让插件改变代理知道或可以做的事情,所以两者都是 在到达模型之前有界:
技能 (agent.prompt.inject) — 系统提示符仅携带目录 (id、名称、一行描述,上限为 240 个字符);按需读取正文 通过内置的 Skill 工具。一个插件最多可以教授 32 个技能,每个技能 文档最多 128KB。未经许可,技能将被简单地跳过: 清单仍然有效,没有任何提示。
MCP 工具 (mcp.server.local / mcp.server.remote) — 已发现的工具是 与手写插件工具注册在相同的 plugin_* 命名空间下 因此继承工具超时、审计跟踪和每个插件禁用 转变。它们始终在 risk: "medium" 处注册:它们的架构和 描述来自第三方服务器,因此主机无法信任 自我声明的风险水平。服务器的目录被完整注册——数量只受 §8.1 中的 协议护栏约束——而每个插件最多接入 8 个服务器。
Plan 是代理工具的附加主机策略边界:
- Plan 中没有可见或可执行的插件工具;
- 拒绝先于明显风险、declared/granted 权限、会话 grants,以及
auto权限模式; - 直接伪造的
tools.execute调用返回PLUGIN_DISABLED_IN_PLAN并且是 已审核;它不会转发到插件运行时; - 插件命令和面板仍可用作显式用户 UI 操作, 但它们不能成为模型可调用的 Plan 工具或默默地改变 Plan 状态。
8. 网络和外部链接
- 默认情况下不授予
net.fetch openExternal应确认。主机解析 URL,只打开http:、https:和mailto:(D330 / ADR 0168);其他 scheme 以INVALID_ARGUMENT失败,不会到达shell.openExternal。fs.openDefault和fs.reveal是独立能力:二者都只接受已经通过插件fs.read策略检查的现有 root-relative 文件,用于明确的文件查看操作,不允许任意 URL 或绝对 路径打开;fs.reveal只请求操作系统文件管理器选中该文件。- 禁止插件静默下载和执行二进制文件(MVP 中根本没有这样做)
8.0 出网白名单
一个权限没法表达「读得宽但什么都漏不出去」,所以范围放在清单里: net.domains 是每个插件唯一的一份主机名白名单,主机掌握的每一条出网路径 都听它的。
- 面板 session。
sandbox: true去掉的是 Node,不是网络,所以面板以前 是一个完整的浏览器,而且从不经过net.fetch。现在该 session 跑一个webRequest过滤器、拒绝所有设备权限,并禁止window.open—— 否则它会造出一个不受过滤的窗口 pi.net.fetch。 检查白名单并手工跟随重定向,因为一个被允许的主机 30x 跳到未声明的主机上,就会把请求带出去。运行时的逐跳循环是唯一的 fetch 路径:Electron 主进程不提供任何替代的fetch服务,所以没有东西 能绕过逐跳重新检查去跟随重定向- 远程 MCP 端点。 同样听这份列表,而不是只看它自己的权限。HTTP 端点可以 位于可信局域网,但明文 HTTP 不加密,配置或插件权限审查时会明确提示。 MCP 客户端手动跟随重定向,最多允许五次 HTTP(S) 跳转,并在每一跳之前重新 检查白名单。
pi.net.websocket。 目标主机不在manifest.net.domains里的ws://或wss://会在出网卡点处被拒绝,传输根本不会被要求去打开任何东西;而且持有 套接字的是宿主、不是插件,所以插件卸载、被禁用或崩溃时,宿主会关闭它仍 持有的每个套接字。套接字按插件设上限(4 个),入站和出站帧都封顶 1 MiB, 超大帧会关闭连接而不是被缓冲,超过 4 MiB 的发送队列会被拒绝而不是继续 增长。帧只会送达持有它的那个插件。
列表缺失、为空或非法就完全不放行出网,而裸 * 在安装时被拒绝, 免得有人靠声明绕出去。正是这一点让一个宽松的 fs.read 范围变得可以接受(§6)。
仍然敞着、单独跟踪的:agent.prompt.inject(技能文本可以让一个有 shell 能力的 Agent 替它搬运)、shell.openExternal、bus.publish 转给一个有网络 能力的插件,以及插件进程里的原生 fetch —— 最后一项需要 ADR 0008 D009 的沙箱化插件运行时。pi.net.fetch 并不会缩小这些缺口:宿主只负责套用白名单、 手工跟随重定向并审计这次调用,它从不重试、不限流、也不重新发起请求 —— 上游的 429 会带着服务器给出的 Retry-After 原样到达插件,插件怎么处理是插件自己 的策略。
8. 1 MCP 服务器出口和凭证
MCP 服务器是 net.fetch 旁边的第二个出口路径,因此它是声明性的 并且是可审查的而不是编程的——插件无法打开连接 清单未命名:
transport: "stdio"生成本地可执行文件 (mcp.server.local)。的command必须是裸路径名称或插件相对路径;绝对路径 在验证时被拒绝。子进程拿到的是最小环境——声明的env条目,加上共享 白名单(child-process-env.ts):PATH、SystemRoot、windir、TEMP、TMP、TMPDIR、LANG、HOME、USER、USERPROFILE。身份变量要透传, 是因为子进程是第三方代码,用$HOME解析~而不是调用os.homedir()(issue #717);provider key 和其它宿主状态仍然不会穿越。transport: "http"到达远程端点 (mcp.server.remote)。url可以使用http或https;非回环 HTTP 不加密,只应在可信网络中使用。插件端点还 必须被manifest.net.domains覆盖。工具参数会离开机器,这就是为什么权限 文案必须明确说明。env和headers值仅通过插件自己的设置进行解析{ "setting": "<key>" }。宿主环境永远不会被穿越,并且 清单中的字面秘密是审查气味,而不是受支持的模式 (D018)。- 连接预算:完成
initialize需要 10 秒,每个tools/call需要 100 秒,每条 stdio 线 4MB。tools/list在 §8.1 的每服务器护栏下跟进到最后一页 ——2048 个工具、100 页、重复或畸形游标、整轮遍历 30 秒——突破任一护栏的服务器会被 拒绝,而不是贡献其目录的一个前缀,因为 MCP 工具是以延迟加载的按需条目 (ToolSearch之后)而非常驻列表的形式到达模型的。服务器按需连接, 并在插件卸载或禁用时关闭。
8.2 桌面控制与设备访问
desktop.control 把本地 MCP 控制平面暴露的那份已审查操作目录(ADR 0203 / D370)交给插件:项目、会话、Agent 和工作区操作,每一项都标记为 read、 write 或 dangerous。plugin-only 例外涵盖六个 session/collaboration/* 操作:它们可以经由插件网关调用,却被刻意排除在 MCP 可见目录之外,因为它们 需要已认证的插件调用上下文,且渲染器没有任何变更通道。插件看到的是 id、描述和 风险等级,绝不会看到 Electron 通道名或 MCP bearer token;每一次调用都经过与 MCP 调用相同的 IPC 校验、生命周期检查、完成事件和审计条目。
dangerous 操作由用户拍板,而不是由调用者。控制器的 confirm: true 只是 插件的知会(MCP 也是同样处理,D372)。在此之后,宿主弹出一个原生对话框, 点名目录中的操作 id、目录描述和一段有界的参数预览,并刻意不显示任何由插件 或其背后模型撰写的文本,因此一份被提示注入的转录本无法把 session/delete 重新包装成无害的东西。Escape 和关闭对话框都视为拒绝。没有对话框服务的 无头宿主会直接拒绝所有 dangerous 操作。
ui.microphone 只在插件的隔离面板 session 内允许 media 权限,且仅限音频。 摄像头和其他所有设备权限仍被拒绝,插件也拿不到任何原生句柄:采集始终由 页面持有。
audio.capture.background 和 audio.playback.background 把关一个可以调用的 表面:十个 pi.audio.* 方法都存在于插件宿主进程中,并保留各自的权限要求, 但这条分支没有设备后端,所以获得授权的调用会以带错误码的 UNSUPPORTED 拒绝并记入审计(audio.<method>、ok: false),不会打开任何设备(两个同步 注册辅助函数 onInputFrame / offInputFrame 改为抛出同一个错误码,而不是 注册一个永远不会触发的处理器)。等宿主服务落地后,设备由宿主持有:插件只 交换 PCM16 帧,永远拿不到 MediaStream、设备句柄、操作系统设备路径或 Node 流,每个插件只允许一条输入流;禁用、卸载、崩溃或撤销权限会停止采集并丢弃 已排队的播放,而不会留下孤立的设备或计时器。
keyboard.globalShortcut 已实现,并且始终留在宿主的注册模型之内。宿主持有 Electron 的 globalShortcut;插件永远拿不到键盘钩子、before-input-event、 原始输入设备或按键事件流,所以不存在键盘记录器形状的表面,也无法看到用户 按下的键。插件只能把加速键映射到自己已注册的一条命令;被操作系统保留、被 PI-Desktop 自己当前占用(默认是 Alt+Space 与 Alt+Shift+W;用户改绑后 即可释放给插件)或已被另一个插件持有的 加速键会被拒绝,返回 SHORTCUT_CONFLICT / SHORTCUT_UNAVAILABLE / INVALID_ACCELERATOR / LIMIT_EXCEEDED(每个插件最多 8 条),而不是被抢走。 一次触发只运行那一条命令。注册、注销和触发的审计会记录插件 id 与结果 —— 注册和触发还会记录加速键与命令 —— 绝不记录用户输入。每条条目都在禁用、 卸载和崩溃时释放。
四种能力一律失败即关闭:未声明或未授予权限时,调用在触达任何设备、加速键 或套接字之前就会被拒绝并记入审计。
9. 审计和应急响应
用户应该能够:
- 查看插件权限
- 查看插件错误日志
- 一键禁用
- 一键卸载
主机应该能够:
- 出现异常时自动禁用插件
- 保证主应用程序可以启动
10. 安全验收
- 没有
fs.write权限写入文件失败,manifest.fs.write.scope之外的写入 会问用户 - 没有
fs.delete权限删除文件失败;删除永不递归、落进系统回收站, 并在滚动一分钟内超过 50 次后被打断 3.禁用插件后,其工具不再可见 - 插件无法读取 API 键 5.插件面板无法调用任意主机IPC 6.插件未捕获的异常不会导致应用程序退出
- 带有
@import或远程url()的主题 CSS 文件被拒绝,并禁用 提供的插件将应用程序返回到system主题 - 发布到未声明的主题失败,发布者永远不会收到它的消息 自己的留言
- 声明绝对路径
command的 MCP 服务器会在 manifest 校验时失败;非回环的 明文 HTTP URL 只有在主机声明于manifest.net.domains且 UI 显示未加密连接 警告时才可接受 - 低风险或授予的插件工具在 Plan 中仍然无法关闭
11. 实施情况
目前执行情况:
PluginRuntime中的默认拒绝权限检查,取「已声明 ∩ 已授予」的交集, 所以被撤销的权限会真的失效- 插件 fs API 有对软链安全的 containment 加一份无条件 deny-list,每种文件 模式的可达范围由
manifest.fs限定,范围之外落到原生确认(§6) 3.面板窗口使用沙盒preload+隔离会话分区 - 插件仍然无法访问 Secrets/host DB
- Marketplace/package 安装需要在 UI 中明确接受权限 6.自动更新拒绝静默权限扩展
- 插件主程序在每个插件专用的
utilityProcess(ADR 0008) 中运行,环境来自 共享白名单child-process-env.ts(PATH、工具链目录、HOME/USER/USERPROFILE,不含 provider key);所有pi.*调用都跨越白名单 + 权限网关 在主机中,插件崩溃只会破坏该插件 - 贡献的主题 CSS 在到达主进程之前会在主进程中进行清理 渲染器(§3.1)
- 总线路由由主机拥有,并具有声明的主题和硬上限(§5.1)
- MCP 服务器仅从以下位置声明、权限控制和提供凭证: 插件设置(§8.1)
- 出网被限制在
manifest.net.domains之内,覆盖主机掌握的每一个卡点(§8.0) - 插件的删除进系统回收站、不递归,并有速率刹车(§6.1)
manifest.main和ui.panel在安装时被校验为相对路径,并在宿主加载 它们之前,以与技能和主题 CSS 相同的插件目录内 containment 进行解析- 来自插件的
dangerous桌面操作,在插件自己的confirm: true之后, 还需要用户在宿主拥有的原生对话框中作答;对话框只显示目录文本(§8.2) ui.microphone只授予音频采集,且仅限隔离面板 session 内(§8.2)keyboard.globalShortcut由宿主持有:注册表拒绝被操作系统保留、宿主 自己占用或属于其他插件的加速键,快捷键只能运行持有插件自己的命令, 每条条目都与插件的命令和工具走同一条清理路径(§8.2)
audio.capture.background 和 audio.playback.background 已声明并且存在于 插件 API 中:这些方法由这两个权限把关,获得授权的调用会以带错误码的 UNSUPPORTED 拒绝并记入审计,因为当前宿主还没有设备后端,所以没有任何东西 能到达设备。 net.websocket 已实现:连接由宿主持有、经过白名单检查、有界,并随插件一起 释放(§8.1)。
尚未强制执行:
- 插件进程内的能力沙箱(Node 内置模块在那里仍然可达,所以
fs.*权限 把的是插件 API,不是进程)。这是真正还剩下的缺口:§6 和 §8.0 里的一切 约束的是一个按规矩用 API 的插件,不是一个绕过 API 的插件(ADR 0008 D009) - CPU/内存限制
- 签名验证(仅对包进行 sha256 检查)
- 声明的清单权限在加载时自动授予
userSelectedroot 不跨重启保留,插件每个会话都得重新问一次