ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

GPUI Shell 能力模型实战:从默认拒绝到最小授权 —— gpui-shell 的能力清单、存储与沙箱机制解析

2026/9/15 15:55:12 拓冰建站 浏览量
GPUI Shell 能力模型实战:从默认拒绝到最小授权 —— gpui-shell 的能力清单、存储与沙箱机制解析 GPUI Shell 能力模型实战从默认拒绝到最小授权 —— gpui-shell 的能力清单、存储与沙箱机制解析【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit本篇文章深入讲解 gpui-kit 项目中gpui-shell的完整能力Capabilities模型脚本默认一无所有一切文件、剪贴板、进程、网络访问都必须由宿主显式授予。读完你将掌握gpui-shell.json清单的字段语义与验证规则、fs/localStorage/ 剪贴板 /process/console各 API 的授权边界与调用细节以及运行时对语言本身施加的沙箱裁剪能够独立编写一份最小权限的脚本应用清单并理解拒绝信息背后的安全设计。默认拒绝脚本什么也得不到gpui-shell 的安全模型有一个绝对前提脚本默认什么权限都没有。没有文件访问、没有剪贴板、没有进程执行、没有网络。Capabilities::default()就是空集并且有断言把它钉死在那里——不是默认开放、可选收紧而是默认关闭、可选放开。唯一的例外是 storage存储而且只发生在清单manifest层面一个没有提到storage字段的应用会得到自己的localStorage就像浏览器会不问自答地给每个 origin 发一个一样。这是关于作者必须写什么的约定而不是模型上的漏洞——Rust 端的Capabilities仍然默认拒绝它直到宿主显式放行清单里也仍然可以写storage: false来拒绝它。详见下文 Storage 一节。授权由宿主决定因为只有宿主知道它将要运行的代码值得多大信任。宿主主动暴露给自己的 Rust 接口通过 HostModule 借给脚本。一个 View 在加载时冻结它的能力集——之后修改默认值只影响后续加载的应用永远不会作用于已经在既定授权下运行的代码。在 Rust 侧宿主通过gpui_kit::shell::set_capabilities授予能力gpui_kit::shell::set_capabilities( Capabilities::new() .read_roots([application_root.clone()]) .write_roots([data_directory.clone()]) .storage(true) .exit(true), );这一调用在 crates/shell/src/lib.rs 中实现它把授权写入默认的policy::Policy见 crates/shell/src/policy.rs。从源码结构可以确认Capabilities的每个字段都是私有的见 crates/shell/src/capability.rs这样将来新增能力不会对嵌入方造成破坏性变更。本地运行一个目录时宿主授予什么从命令行运行一个目录是显式的信任行为——和node app.js一样——所以gpui-shell directory授予一组特定而狭窄的权限能力本地运行时的授予情况读Read应用目录以及它自己的存储目录写Write它自己的存储目录存储Storage授予剪贴板Clipboard不授予进程执行Process execution不授予退出请求Exit request授予网络Network不授予因此一个应用可以读取自己的源码和资源、使用自己的存储仅此而已。这份授权刻意比全部更窄因为将来安装的插件会走同一条代码路径只是由清单来决策——对本地运行来说慷慨的授权不是应该被继承的默认值。这个本地运行与插件同路径的决策在 crates/shell/src/host.rs 的 CLI 引导代码中可以看到bundle_id_for_path生成身份、set_bundle_id定位存储、local_capabilities组装本地运行的窄授权集。拒绝信息直接指出修复方法每一条拒绝都以该声明什么结尾而不只是拒绝的事实。这样脚本作者和调试者能立刻知道要改哪里filesystem read is not granted; declare capabilities.fs.read in the manifest/etc/passwd is outside every granted read root; add its directory to capabilities.fs.read in the manifeststorage is not granted; set capabilities.storage to truerunning git is not granted; add it to capabilities.fs.execute in the manifestprocess.exit() is not granted; set capabilities.process.exit to true in the manifest这些文案并非文档示例而是 crates/shell/src/capability.rs 中CapabilityError的真实Display实现——NotGranted、OutsideRoots、ExecuteDenied、StorageDenied四种错误各有一套指向明确修复动作的措辞。清单 gpui-shell.json一个目录靠gpui-shell.json被识别。清单是惰性数据——发现过程只读取身份、可选的版本元数据、Git 依赖和请求的权限不执行入口模块。它识别id、name、version、shell-version、entry、dependencies和capabilities其中只有id、name、entry是必填的。{ id: com.example.quotes, name: Quotes, version: 1.0.0, shell-version: 0.6.0, entry: main.js, dependencies: { omarchy-ui: huacnlee/omarchy-ui }, capabilities: { fs: { read: [${pluginDir}], write: [${dataDir}] }, network: { hosts: [stream.example.com], http: [ { scheme: https, host: api.example.com, methods: [GET], path_prefixes: [/v1/] } ] }, storage: true, clipboard: { read: false, write: true }, process: { exit: false } } }dependencies把裸模块名映射到一个在入口模块运行前从 Git 拉取的 JavaScript 包——import { Title } from omarchy-ui。字符串形式接受严格的 GitHub 简写或带可选#ref的完整 Git URL带显式branch或tag的对象形式仍然受支持。每次加载还会在编辑器能找到的地方链接该包这样 import 会带上包自己的类型和文档。版本选择、包入口、缓存以及编辑器看到什么见 Dependencies。该块中的每一项授权在缺省时默认都是拒绝的除了storage默认授予——写storage: false即可拒绝它。以下情况会在任何代码运行之前使清单失效未知字段、非法的反向域名reverse-DNSid、显式声明的非法 SemVer 值、不兼容的shell-version、转义条目escaping entries、未知的${...}占位符。省略version会被报告为unknown。省略shell-version表示接受当前运行时一旦写上它命名的是该应用要求的最老的兼容 gpui-shell 版本——任何等于或高于该版本的运行时都被接受。独立 CLI 会拒绝无效清单而不是在静默的、不同的假设下执行它的入口。清单解析器的这些规则可以在 crates/shell/src/plugin.rs 中看到例如占位符限制在${pluginDir}与${dataDir}之内错误信息见该文件的unknown placeholder分支。每个作用域化的network.http规则除了绑定主机、方法和路径之外还绑定请求的 scheme 和有效端口effective port。scheme默认为httpsport默认为该 scheme 的标准端口只有非默认端点才需要写。这与 crates/shell/src/capability.rs 中HttpRequestGrant的实现一致effective_port对 http 取 80、对 https 取 443路径匹配既支持精确路径也支持前缀并要求前缀以/结尾或后续字符以/开头避免/v1误匹配/v10/x。fs文件系统import * as fs from fs/promises;每个调用都返回一个 promise。await它们或者链式.then——并注意下面关于render的提示。调用解析为fs.readFile(path)Uint8Arrayfs.readFile(path, utf8)UTF-8 文本fs.writeFile(path, contents)—fs.readdir(path)按名称排序的名字列表fs.readdir(path, { withFileTypes: true })带isDirectory()的Dirent[]fs.exists(path)true/falsefs.unlink(path)—fs.rmdir(path)—fs.mkdir(path, options?)—const source await fs.readFile(notes.md, utf8); await fs.writeFile(notes.md, source \n);相对路径在某个已授予的根内解析绝对路径必须已经在某个根内。这个表面上的每个路径都经过同一个解析器所以没有第二个地方可以藏一个路径穿越traversalbug。它先规范化路径——../../etc/passwd在到达文件系统之前就被拒绝——然后针对文件系统而不是字符串来裁定包含关系因为授权是对一个目录的承诺data/escape/passwd在词法上位于根内但如果escape是一个符号链接它就会读到/etc/passwd。路径中最深的存在部分会被解析包括所有链接并且仍然必须位于根之下解析到虚无的符号链接会被拒绝而不是被猜测。授权是句柄不是字符串解析器交回一个打开的目录句柄它无法被引导去命名自身之外的任何东西每一次读、写、列目录、删除和 mkdir 都针对那个句柄执行——所以一个路径永远不会被解析两次在判定允许和使用它之间不存在窗口。这一点很重要因为显而易见的实现是不起作用的先检查路径再调用std::fs会把路径解析两次——已经存在的链接会被检查捕获但在两次解析之间替换掉某个目录组件的链接则会被第二次解析带出根目录。这就是cap-std的做法在 Linux 上是openat2(RESOLVE_BENEATH)在其他平台上是逐组件的openat遍历。实现上Capabilities::open返回的Grant { dir, relative }结构crates/shell/src/capability.rs正是持有打开目录即拥有能力的体现Dir::open_ambient_dir打开的句柄把权威握在手中而非描述在字符串里。同一解析器同时服务于fs模块、受能力门控的os.*函数和资源源因此不存在第二套需要保持同步的路径策略。五个值得单独说明的行为它们各自出于同一个原因被拒绝的路径抛出异常而不是回答false。你不许看和它不存在是两个不同的事实把它们合并会让脚本一次一个布尔值地测绘出它根目录之外的文件系统。删除一个文件和删除一个目录是两个调用就像在 Rust 里一样因为光说 remove 并不说明目录是否在作用域内。remove_dir只接受空目录别的都不行写权限是按根授予的所以递归删除会把一次路径笔误变成整个应用数据目录的丢失。真想递归删除的脚本得自己遍历树。mkdir的含义和它在任何其他地方的含义相同。裸调用创建一个目录父目录缺失时失败{ recursive: true }会连带创建父目录。它曾经叫create_dir_all——这个名字说出了它做什么但代价是它不是每个脚本作者都已经认识的名字。read_dir是排序的。渲染列表的脚本不应该自己排序也不应该继承文件系统任意的顺序。每个调用都返回 promise。系统调用在非主线程上运行因为磁盘没有时间上限在这里阻塞会同时停住帧和 VM——而且是在中断预算看不见的地方因为时间花在内核里。拒绝与上限拒绝仍然在调用点抛出而不是 reject。能力检查几乎不花成本而且停留在调用线程上一个无人 await 的被拒绝 promise 是一个无人看见的拒绝。readFile拒绝超过 64 MiB 的文件并指名文件和限制。天花板的反面是一个必须塞进 JavaScript 堆的字符串——而堆本身是有上限的——所以没有上限时的失败是 VM 内部的 OOM而不是一句你能采取行动的提示。writeFile每次调用最多接受 8 MiB。readdir在 10,000 个条目或 1 MiB 的 UTF-8 名字字节处停止先到先停这样对抗性的目录无法把一次 promise 变成无界分配。::: tip 仍然不要在render里读文件render描述界面它无法 await。在init或事件处理器里读把结果保存在 View 上到达时调用cx.notify()。 :::这些行为的端到端验证在 crates/shell/src/tests/fs.rs测试用一段真实的脚本fs.writeFile、fs.readFile、fs.readdir、fs.mkdir、fs.unlink全链路证明第一帧渲染时工作仍在飞行中状态是pending随后 promise 全部落定并得到正确结果。拒绝路径rejected:...状态同样有专门探针覆盖。符号链接穿越的防守则在 crates/shell/src/capability.rs 的一组 Unix 专属测试中读穿链接、写穿链接、检查之后才种下的链接TOCTOU、悬挂链接四种情形都被验证为失败。StoragelocalStorage 与 sessionStorage即浏览器里的 [Web Storage API]。无需导入localStorage和sessionStorage是全局对象也存在于window上。localStorage.setItem(todolist.items, JSON.stringify(items)); const saved localStorage.getItem(todolist.items); // null when the key is unset localStorage.removeItem(todolist.items); localStorage.length; localStorage.key(0); localStorage.clear();成员描述length存了多少个键key(index)该位置的键或nullgetItem(key)该键的值键未设置时为nullsetItem(key, val)存入把值转换为字符串removeItem(key)忘掉一个键clear()全部忘掉flush()在写入到达磁盘后解析两者的区别只在存续时间。localStorage是宿主放置的一个文件重启后仍在。sessionStorage是随进程消失的内存。这也是为什么只有其中一个是能力sessionStorage里没有任何东西会离开进程所以没有什么可授予的它在什么都没授予的宿主上也能工作。值都是字符串和 web 上完全一样——setItem会把递给它的任何东西转成字符串。有结构的东西进入时走JSON.stringify、出来时走JSON.parse和你在浏览器里写的代码相同localStorage.setItem( window, JSON.stringify({ title: Notes, size: [640, 480] }), ); const window JSON.parse(localStorage.getItem(window) ?? {});每个成员都是同步的而且是故意的getItem可以从render触达所以值缓存在内存中读取直接从缓存回答。每帧读一次文件是荒谬的。一次变更安排写盘而不是立即写。文件在后台线程写入——先写临时文件再重命名覆盖目标所以写盘中途崩溃留下的是之前完好的设置而不是截断的文件——并且同一时刻只有一个写在途于是一连串setItem调用变成一次文件写入而不是每个调用一个文件。写盘进行期间发生的变化由下一次写带走。需要确认落盘时await localStorage.flush()。这是对浏览器接口的唯一一处新增它存在是因为浏览器永远不需要回答这个问题——它的存储一路同步到底。它是一道屏障而不是第二个写者它等待此前所有写入到达磁盘若没到达则用写入自身的错误 reject。自己另起一个写盘会和自动写盘竞争同一个临时文件且没有任何东西给它们排序——旧版本可能最后落盘撤销掉新版本。缓存和它的等待队列是有界的一个存储文件序列化后最多 8 MiB、最多 4,096 个键、任意单个值最多 1 MiB。同时最多 1,024 个未决的flush()屏障在等待再来一个会被拒绝而不是长成一个无界的等待者列表。存储放在哪里存储按应用隔离位置由宿主选择——应用不能给自己命名存储位置否则两个应用可以故意撞在一起。宿主给应用命名数据跟着名字走let data gpui_kit::shell::set_bundle_id(com.example.notes)?; gpui_kit::shell::set_capabilities(Capabilities::new().write_roots([data]));平台位置Linux 和其他 Unix$XDG_DATA_HOME/gpui-shell/apps/id/store.json默认~/.local/sharemacOS~/Library/Application Support/gpui-shell/apps/id/store.jsonWindows%APPDATA%\gpui-shell\apps\id\store.jsonid 就是身份所以数据在目录被重命名、移动或由升级替换后依然存活——这正是用户所说的我的设置。如果以路径为键一次升级就会静默地把他们打回原形。set_bundle_id的实现在 crates/shell/src/lib.rs它计算数据目录、把store.json设为存储路径并且用同一个名字给该应用的 dock 面板命名空间shell:id/panel因为存储和持久化布局是两件必须靠名字而不是路径存活过重启的东西。运行时不会去文件里找这个 id。只有安装应用的那一层知道它叫什么一个按自己喜好从清单里读出 id 的运行时是在对它不拥有的东西宣称权威。一个只是被指向一个目录的宿主——命令行、开发服务器——没有这样的名字在那里路径真的就是身份。gpui_kit::shell::bundle_id_for_path(root)用目录名加上其完整路径的摘要来构造一个 idcrates/shell/src/lib.rs于是同一个目录总是到达同一份数据同一份源码的两个 checkout 则彼此分开。这在编辑时是正确的安装后就是错的——这恰好就是声明一个真实 id 所带来的区别。id 可以包含a-z、0-9、.、-和_不能有..。这不是洁癖它要被拼接到用户的数据目录上未受检查的 id 会够到数据目录的其余部分。数据住在应用目录之外因为应用目录可能是只读的、常常是 git checkout而且用户并不期望数据在那里。未获授权时优雅降级未被授予的localStorage会抛出异常而一个写得好的应用会把这件事当作关于其宿主的事实而不是错误// storage.js — from the bundled example export function load() { try { const saved localStorage.getItem(KEY); if (saved null) return []; const items JSON.parse(saved); return Array.isArray(items) ? items : []; } catch (error) { console.warn( todolist: storage unavailable, starting empty (${error.message}), ); return []; } }这个真实示例位于 examples/js_todolist/storage.js整个模块存在的意义就是把存储不可用收敛到一个边界上。示例的界面底部随后如实告知用户——Not saved — this host did not grant storage, so the list lasts for this run only——这正是正确的姿态在边界吸收拒绝并把真相告诉用户。剪贴板cx.write_to_clipboard(copied); const text cx.read_from_clipboard(); // undefined when the clipboard holds no text命名继承自App::write_to_clipboard和App::read_from_clipboard放在cx上是因为 GPUI 把它们放在那里。无需导入。读和写是两个独立的授权一次拒绝会指名缺失的是哪一半writing the clipboard is not granted; declare capabilities.clipboard.write in the manifest剪贴板需要一个存活的宿主调用——GPUI 的App只在一个调用期间存在——所以没有这种调用的cx会直说而不是 paniccx.read_from_clipboard() needs a live host call; call it from render, an event handler or a taskconsoleconsole.info(loaded, count, { source: disk }); console.warn(could not save);提供debug、log、info、warn和error。它是全局对象和每个 JavaScript 运行时里一样无需导入——shell 曾经把同一个对象作为gpui.log再导出一次那只是买了一个名字别无他物。不要求任何能力一个能运行的脚本本来就能说话拒绝它只会损失作者的诊断信息什么都保护不了。额外参数以空格分隔追加和console.log的行为一致。结构化值打印为 JSON因为那是读日志的作者想看到的东西。输出经由tracing以gpui_kit::shell::script为目标target发出所以脚本输出可以在日志过滤器中和宿主输出分开。没有安装tracingsubscriber 的宿主会丢弃全部输出——连同运行时自己对抛异常处理器、未处理的 rejection 和非法阶段调用的报告。gpui-shell二进制安装一个INFO级别的 stderr sink--dev下为DEBUG。processimport process from process; // also available as a bare global const { code, stdout, stderr } await process.run(git, [status]); process.exit(0);process.run返回 promise理由比fs更尖锐。文件读取没有上限子进程更甚——它可以计算几分钟、等待永远不会来的输入、或者比窗口活得更久。在这个线程上等它会同时在内核里停住帧和 VM而那里是中断预算看不见的地方。输出是捕获而非继承运行一条命令的脚本几乎总是想要它说了什么而在窗口应用里子进程写到宿主的 stdout 就是写到一个没有用户会看的地方。成功时code为0被信号杀死时为-1——信号没有自己的退出码。执行是有界的30 秒、8 MiB stdout、8 MiB stderr。触及边界会杀死并回收子进程、reject 该 promise。取消所属的工作或拆除其运行时也会终止子进程。子进程以清空的环境启动而不是继承宿主机密shell 不提供添加环境变量的选项。它由一个 execute 授权门控该授权有三种状态拒绝默认、命令名白名单、或不受限制。被拒绝的命令在调用时抛出而不是 reject和拒绝的fs路径一样——无人 await 的 rejected promise 是无人看见的拒绝。三态模型的源码在 crates/shell/src/capability.rs 的ExecuteGrant枚举may_run是门控判定。process.exit是一个请求绝不是运行时内部的exit(2)。它把代码交给宿主安装的处理器由处理器决定做什么——关闭插件的面板、关闭窗口、结束进程。一个插件绝不能有能力拉倒宿主进程而且宿主可能有未保存的状态。处理器不是可选的宿主授予了能力却没安装处理器调用就会失败并指名这一遗漏。无人应答的请求比拒绝更糟因为脚本无法区分二者。gpui-shell二进制安装了适合宿主即进程的处理器——它用脚本要求的退出码结束进程。这个名字是刻意的碰撞。process是 JavaScript 作者——或生成 JavaScript 的模型——会伸手去够的名字所以运行时把能力门控的表面放在那里而不是让这个名字空着看起来像 Node 的、行为却不同。process.exit有自己独立的capabilities.process.exit授权。文件系统访问绝不意味着允许关闭面板、窗口或进程。沙箱对语言本身的裁剪在能力授权之下运行时还修剪语言本身。除非开发模式开启全部生效。没有动态代码。globalThis.eval被直接删除——ReferenceError不会被特性检测误认为是可用的eval而抛异常的桩throwing stub却可能。四个函数编译器全部被替换Function以及通过(async function(){}).constructor、(function*(){}).constructor和 async-generator 对应物触达的构造器。Function是被替换而非删除保留了真正的Function.prototype所以x instanceof Function和.call/.apply/.bind继续工作只有构造会抛。冻结内建原型。Object、Array、Function、String、Number的原型被冻结。一个 VM 将托管多个插件这使得这些原型成为共享的可变状态一个插件往Object.prototype加一个可枚举属性会改变所有其他插件以及运行时自身 prelude 的for...in。代价是真实的——一个补丁了Array.prototype的库会在 import 时停止工作——所以明知只运行一个插件的宿主可以关掉冻结同时保留沙箱的其余部分。模块解析被限制在应用根目录内。import ./ui.js相对导入它的文件解析任何解析到应用目录之外的东西都被拒绝。动态import()故意保持可调用——懒加载将来就靠它——并被同一个解析器约束。资源上限让失控的脚本报告错误而不是带走窗口上限值堆Heap256 MiB——泄漏变成可捕获的 JavaScript 异常而不是 OOM 杀死解释器栈1 MiB——深递归变成RangeError而不是原生栈溢出加载的 JavaScript 模块每个源文件 8 MiB未完成的宿主任务每个运行时 1,024单次调用内的时间render 和 layout50 ms单次调用内的时间事件和任务500 ms单次调用内的时间任何调用之外如模块求值5 s时钟在每次宿主调用时重新开始这正是让渲染路径比事件处理器拥有更紧预算的原因。中断不能被catch块吞掉——这是由测试度量的因为如果它能被吞掉中断就根本不是防线。每个 WebSocket 还有一个由read、write、close共享的 8 条命令队列队列满时新操作 reject 并告诉调用者等待未完成的工作。构建中没有 quickjs-libc 的stdquickjs-libc 没有被编译进来。运行时确实提供下面列出的、小而经过审计的os模块。::: tip 开发模式--dev开启源文件监听并在构造运行时之前调用gpui_kit::shell::set_development_mode(true)实现见 crates/shell/src/lib.rs。它恢复动态代码构造器、让内建原型可写。开发模式从不放松能力门控。它只是让语言更容易摆弄它不会发放没人声明的访问权因为作者从没写下的授权正是生产环境里会缺失的授权。 :::网络与安全的标准 API全局fetch(url, options?)基于 promise返回{ status, ok, url, json() }。它的授权比裸网络更窄每个请求和重定向都必须匹配已声明的 HTTP 主机、方法和精确路径或路径前缀HTTPS 永不降级为 HTTP授权头或调用者提供的头永不跨 origin。net.connect(host, port)和来自websocket的具名导出WebSocket.connect(url, { headers? })使用capabilities.network.hosts。WebSocket不作为浏览器全局安装也不是构造器。裸 TCP 的read()返回Uint8ArrayEOF 时为null所以传输块永远不会经过有损的文本解码。WebSocket 支持文本和Uint8Array消息并通过一个 actor 串行化写入。它们不跟随重定向。连接、握手和写操作有 30 秒超时。一个 socket 同时只允许一个未完成的read()第二个立即被拒绝而不是竞争下一条消息。凭证头和握手控制头被拒绝。裸 TCP 和 WebSocket 访问刻意比 HTTP 请求授权更宽。DNS 解析是一个有界的进程级服务所有应用共享两个解析器 worker 和一个 64 请求的队列。排队会观察每个连接已有的截止时间所以饱和表现为超时失败而不是无界地增长内存或线程。这是资源包含不是按应用的服务质量——在同一进程里运行互不信任应用的宿主不会得到它们之间的 DNS 公平。运行时还提供buffer、path、url、crypto、zlib、console、process和os。这些是生成于gpui-kit.d.ts中声明的、经过审计的 LLRT/宿主支持子集node:别名和任意 Node 内建不属于 shell 契约的一部分。尚未实现向用户提问Prompting the user。授权在应用加载之前就已决定没有任何东西在使用的那一刻询问。延伸阅读HostModule宿主把自己的 Rust 借给脚本以及纯数据边界Dependenciesshell 包——如何构成、清单如何命名并固定它、编辑器拿到什么类型能力模型实现Capabilities、HttpRequestGrant、ExecuteGrant、单解析器与符号链接测试策略与冻结授权一个策略回答这段代码、此刻、在谁的权威下测试验证两份授权互不渗透fs 端到端测试promise 化文件系统的真实脚本验证存储降级示例把 storage 不可用吸收为本次运行仅存内存【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考