Skip to content

fix(desktop): 安装与打包 .cindy 时保留 Unix 执行位 - #2948

Merged
MagicLizi merged 4 commits into
makecindy:mainfrom
zhanghengxd:fix/ghost-installer-unix-mode
Aug 18, 2026
Merged

fix(desktop): 安装与打包 .cindy 时保留 Unix 执行位#2948
MagicLizi merged 4 commits into
makecindy:mainfrom
zhanghengxd:fix/ghost-installer-unix-mode

Conversation

@zhanghengxd

@zhanghengxd zhanghengxd commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

这次改了什么

摘要

.cindy 包里声明为 Unix mode 0755 的普通文件,在 macOS / Linux 安装后变成 0644,插件因此无法启动随包的本机可执行程序。文件内容与 SHA-256 都一致,只有 mode 丢了。

排查发现这条链路上有三处丢失点,任一处不修都会让执行位在实际使用中丢掉:

  1. 安装器GhostManager.extractToStaging()fs.promises.writeFile(dest, data) 落盘,不传 mode,也从未读取 JSZip 的 entry.unixPermissions,于是一律落成 0666 & ~umask = 0644。
  2. Cindy 自己的打包器forge.ts / exportGhostPackage.ts 都是 zip.file(rel, content) 不带 unixPermissions,且 JSZip generateAsync 缺省按 DOS 平台写 version made by。也就是说 ghost_forge_pack 与导出产出的 .cindy 压根不带 Unix mode,只有外部 zip / 7z 打的包才带得上。安装器单独修好,forge 打出来的插件依旧没有执行位。
  3. 签名 / 审核重打包signGhostPackage / reviewGhostPackage 会重新生成 ZIP central directory,不带 platform: 'UNIX' 就会把打包侧刚写入的执行位再抹掉一次——发布流水线会静默吃掉修复效果。

新增 ghostZipPermissions.ts 作为三侧共用的唯一归一化入口,避免解包、打包、重打包各写一份掩码。

几条排除掉的怀疑,记录一下省得后人重查:不需要换 ZIP 库(JSZip 本来就解析 central directory 的 external file attributes,zipEntry.jsversionMadeBy >> 8 === 3 时给出 unixPermissions);不是跨卷复制或原子替换丢的(staging → final 全程 rename,同卷保留 mode);全新安装与覆盖升级共用同一个 extractToStaging,所以是一处丢、修一处两条路径都好;内置插件 seed 路径用 copyFile,mode 本来就保留,未改动。

变更类型

  • feat 新功能
  • fix 缺陷修复
  • refactor / perf 重构或性能优化
  • docs / test / chore 文档、测试或工程维护
  • 其他:

范围

  • 关联 Issue / 需求:安装器未恢复 .cindy 中保存的 Unix 可执行位
  • 本 PR 包含:.cindy 安装侧权限恢复;forge / export 打包侧写入 Unix mode;签名 / 审核重打包保留执行位;对应回归测试
  • 明确不包含:
    • 存量已装插件不做权限回补(已与需求方确认)。已经是 0644 的插件靠用户重装或升级插件自然恢复,本 PR 不加启动扫描或迁移脚本。
    • skillhub 不在本次范围skillhub/installService.tsimportLocalSkill.tszipPacker.ts 有同源的裸 writeFile / 不写 mode 问题,但按约定的范围本次不动,需另开 issue 跟进。
  • 用户可见变化:macOS / Linux 上安装带本机可执行程序的插件后,该程序能正常启动,不再需要用户手工 chmod +x
  • 是否存在 breaking change:无

UI 变化

不涉及。

  • 引用的设计规范:不涉及

怎么验证的

自动验证

pnpm test:unit:related
结果:PASS apps/desktop unit (24.3s),退出码 0
      related 范围识别为 apps/desktop (related 9)
      6 个 workspace 跳过,原因均为 notApplicable: No collectable tests yet
      (packages/embedding-client、github-client、gitlab-client、
       heartbeat-client、project-context、device-link-protocol)

pnpm --filter desktop run --if-present typecheck
结果:tsc --noEmit -p tsconfig.json,退出码 0,无跳过

定向复验(4 个相关测试文件)
结果:4/4 test files passed,334/334 tests passed
      (GhostManager 234、forge 66、export 27、ghostSignature 7)
      本机 macOS 下 0 跳过

pnpm check:dco
结果:DCO check passed: 1 commit signed off

新增测试覆盖:

  • 同一个 UNIX .cindy fixture 内同时放 0755、0644、4755 文件;
  • 全新安装走 manager.install(v1),断言 mode & 0o777 精确为 0755 / 0644,且 4755 落地为 0755、setuid 位为 0;
  • 随后用 v2 包走 manager.update(...)(不是二次 install),再次精确断言三种 mode,并确认内容已换成 v2 —— 覆盖「全新安装与覆盖升级结果一致」;
  • DOS 包(unixPermissions === null)安装成功,且用 spy 证明完全没有调用 chmod
  • 打包侧往返:forge / export 产出的包重新 JSZip.loadAsync 后能读回 0755,且特殊位已剥除;
  • 签名 / 审核重打包后执行位仍在,声明为符号链接的目录条目降级为 0755。

手工验证

不涉及:本次改动的可观测点就是文件 mode,已由上述单测在真实文件系统上用 fs.stat 精确断言,手工复核不会提供额外信息。

未执行的验证

  • Windows 未做实机验证。Windows 上安装路径整段跳过 chmodinstalledFileModeFromZipwin32 直接返回 null),纯函数级有断言覆盖,实文件 mode 断言在 win32 上按平台跳过,避免假红或假绿。最终以 CI 矩阵为准。
  • 未在 Linux 实机验证,逻辑与 macOS 同路径(同为非 win32 分支)。

风险

风险分类

  • 无已知风险
  • SQLite / migration
  • system prompt
  • 协议兼容
  • 权限 / 安全 / 用户数据
  • 存量插件兼容(批准状态 / 指纹 / manifest 校验 / 安装布局 / 包格式)
  • 原生层 / fingerprint / OTA
  • 跨平台差异
  • 其他:

影响与回滚

影响范围.cindy 的安装、打包、导出与签名重打包路径。

存量插件影响:无需重装、无需重新确认权限、不丢凭证与偏好。 用户升级后什么都不做,已装、已批准、已启用的插件照旧可用——本 PR 只改「新落盘文件的 mode」和「新打包产物的 external attributes」,不碰批准状态 schema、指纹格式、manifest 校验、安装布局与包格式契约,旧 DOS .cindy 继续原样安装且完全不 chmod

关于反向兼容的准确表述(更正初版措辞):新包在旧客户端只保证结构兼容,不保证功能兼容 —— 旧客户端可以正常解包与安装,但它压根不读 unixPermissions(这正是原 bug),因此不具备执行位恢复能力,随包本机程序在旧客户端安装后仍不可直接执行。不宣称完整的反向功能兼容。

需要如实说明的一点:已经以 0644 装在盘上的插件,其执行位不会被自动回补,需重装或升级该插件才恢复。这不是本 PR 引入的退化(这些插件在修复前同样是坏的),也不影响插件的装入 / 批准 / 启用状态,属于「修复不追溯」而非「要求用户重新配置」。基于旧布局的升级用例跑在 GhostManager.test.ts 的覆盖升级断言里。

安全上的取舍(欢迎重点看这一段):

  • 只采纳归档自己声明的普通权限位,没有任何按扩展名或 bin/ 目录猜执行位的逻辑,也不做统一 chmod +x
  • & 0o777 剥除 setuid / setgid / sticky,恶意包无法安装特权文件(有专门用例断言 4755 → 755);
  • 额外 | 0o600 保底 owner 可读写:否则恶意包里 mode 000 的条目会让后续升级 / 卸载变难。不影响验收语义(0o644 | 0o600 === 0o6440o755 | 0o600 === 0o755);
  • 归档声明为非普通文件类型时不落 mode;目录条目走既有 mkdir,zip 属性不影响目录权限,也不会作用到安装目录之外;
  • 既有防御一行未削弱safeJoin 路径穿越 / 绝对路径检查、非规范条目路径判定、符号链接拒绝、zip bomb 的条目数与解压总量上限、__MACOSX/ 过滤全部原样保留。

跨平台差异:Windows 全程不 chmod(那里 chmod 只切换只读位,动它反而是回归)。

回滚 / 降级方式:单 commit,git revert 即可完全回到原行为,无数据迁移、无持久化状态变更。回滚后新装插件恢复为 0644(即回到当前的 bug 行为),不会留下任何不一致状态。

提交前检查

  • 已 review 完整 diff
  • 每个 commit 都带 DCO 签名(git commit -s,见 DCO
  • UI 改动已在「UI 变化」注明引用的设计规范章节(不涉及 UI 则跳过)
  • 未提交凭证、令牌或授权文件
  • 已补充必要文档(在 FORGE_GUIDE 打包步骤里补了一句:随包本机可执行程序需在打包前设好执行位,不要靠扩展名或 bin/ 让宿主猜测)
  • 已确认测试结果或说明未执行原因

Review 反馈处理

两个自动 reviewer 各报了一个 P1,均已处理(2e9f3649)。

1. 已签名导出路径的 mode / 内容快照错位(Greptile) — 成立,已修。readSignedDoc()readSignedEntries() 原先 statreadFile 是两次独立调用,并发 chmod 落在中间会把改动前的 mode 配到改动后的内容上(内容与 SHA-256 自洽,校验发现不了,导出包执行位却是旧值)。现改为从同一个 FileHandle 取 stat 与内容。「先对尺寸再读内容」的顺序、两道 MAX_SIGNATURE_FILE_BYTES 检查、ENOENT → null 与失败收敛为 null 的语义均保留,早退路径经 finally 关闭句柄。

2. mode 未被签名认证(Codex) — 前提属实,已核实:buildStatement() 枚举全部条目并对 (path, sha256, bytes) 做 canonical 全等比对,内容与文件清单都被认证,但 mode 不在覆盖范围内。这个缺口是本 PR 引入的(改之前 mode 被完全忽略)。

处理方式是「修掉可修的那半,另一半显式申明」:

  • 已修:装入侧钳掉 group/other 写位(& ~0o022)。原实现会原样采纳 0o777 / 0o666,于是被篡改的包、甚至源文件权限没设好的正常包,都能把 group/world 可写文件装进插件内容目录,同机另一账号即可改写受害用户随后执行的插件代码 —— 这是比「翻转 +x」更实际的本机提权路径。改前此处恒为 0644,不存在该面。0755/0644/0700 不变,0777 → 07550666 → 0644。只钳装入侧(安全边界),打包侧未动。
  • 不在本 PR 做:把 mode 绑进签名 statement。那是包格式契约改动 —— statement 需升到 schemaVersion: 2,验签必须同时继续接受存量 v1 签名包(存量插件兼容红线,不允许要求重装或重新确认),发布与审核流水线还要重签。已与需求方确认拆为独立 issue,不阻塞本次 bug 修复。

维护者确认门后的第三轮修复(09ac51a8

维护者确认门(#2956)的分析基于 origin/main 快照而非 PR 工作树,其中两条已由 PR 内的提交处理或已过时;三条要求现已全部落地:

  1. mode 必须进入与内容同等的完整性边界 —— 给出依据后维持采纳,已撤回中途那版「签名包不采纳 mode」的分支(b29c750b)。
    中途那版形式上满足要求,但代价是正式发布的插件在 macOS / Linux 上依旧启动不了随包程序、只有未签名包受益 —— 对主分发渠道等于没修(Codex 复审也独立指出了这点)。
    维护者的原话是「现有证据不足以默认做出这一产品/安全决定」,所以补上了实算:钳位之后攻击者改不了文件内容、增删不了文件buildStatement() 全量白名单 + 逐文件 sha256 + canonical 全等)、设不了 setuid/setgid/sticky装不出 group/world 可写文件剥不掉 owner 读写;剩下只有在内容已被哈希钉死的文件上翻转 r / x 位。去掉 +x 只是他本来就能造成的可用性破坏(改坏一字节即验签失败);加上 +x 作用在他无法选择内容的文件上,而执行流指向哪个文件由签名覆盖的 manifest 与插件自身代码决定 —— 拿不到代码执行。逐条分析见 维护者确认 · #2948 fix(desktop): 安装与打包 .cindy 时保留 Unix 执行位 #2956
    失效条件写进代码extractToStaging() 注释):若日后有任何逻辑开始依赖 mode 做安全判断,该前提即失效,届时必须把归一化 mode 纳入认证。并由用例钉住 —— 改成「签名包不采纳 mode」会让它红。
    更省的备选(若放行人坚持要认证 mode):不必改签名格式 —— ghost.json 本来就在签名覆盖范围内,在 manifest 里声明「哪些文件需要执行位」那份声明天然被签名认证,不需要 v2 的双轨验签与流水线重签。要这条说一声我改。把归一化后的 Unix mode 签进 .cindy 签名 statement(schemaVersion 2) #2966(v2 跟进)已按此决定关闭。

  2. 导出快照竞态 —— 已修。同句柄只挡住 open 与 stat 之间的窗口,chmod 落在读取过程中仍能造出「读前的 mode + 读后的字节」。现已在读取后复验文件稳定态,复用 ghostContentTree 既有的 sameStableFileState()(含 ctimeNs,故覆盖 chmod),不另造判据:签名文件不一致即抛错,statement 条目不一致返回 null 交给既有的整体重试。

  3. Windows 门禁要有真断言 —— 已修。原先只是在 win32 跳过 mode 断言。现按平台分叉正面断言:win32 断言 chmod 从未被调用,非 win32 断言确实调用过,避免这条路径以后静默退化成「什么都没做」也能绿。

新增用例:已签名包(真实经 signGhostPackage 用生成的 ed25519 key 签名)安装后,0755 / 0644 与特殊位剥除的断言与未签名包完全一致;用例先自检包内确实带 0755,保证它不是空转。

一处需要单独点明的行为变化(超出「恢复执行位」本身)

既然 mode 现在是包内容的一部分,导出侧的一致性判据也随之收紧,请 review 时留意:

  • 未签名导出的两遍快照比对由 (rel, sha256) 扩成 (rel, sha256, unixPermissions)。因此两遍之间发生一次纯 chmod(内容完全没变)现在会判定快照不一致并触发整体重读,而改动前会被忽略。
  • 已签名导出同理:读后复验含 ctimeNs,读取期间的 chmod 会让该条目返回 null,交给既有的整体重试。

这是必要的收紧 —— 否则会打出「一半旧 mode、一半新 mode」的包,内容与 SHA-256 却都自洽、校验发现不了。代价是导出在「导出期间有人 chmod 插件目录」这种并发下会多重试一轮(重试仍失败才如实报错),属于罕见且正确的失败路径。

残留风险(请放行人重点判断这一条)

签名包与未签名包统一恢复 mode,因此残留面是:能篡改包字节的攻击者可以在验签仍通过的前提下翻转 r / x 位。上面第 1 条已逐条算过后果 —— 只剩一个他原本就具备的 DoS,拿不到代码执行。这是有意接受的取舍,依据与失效条件都已落到代码注释与用例里。

  • 影响:可用性(去掉 +x 使插件启动不了),以及把发布者自己发布、哈希已认证的字节变成可直接 spawn
  • 不能引入新代码、不能增删文件、不能设置 setuid / setgid / sticky(有用例断言 4755 → 755)。
  • 插件的启动入口写在 manifest 里、同样在签名覆盖范围内,翻转别处的执行位改变不了「执行哪个文件」。

请放行人在三者中裁定:接受本 PR 现状(依据如上)、要求改成 manifest 声明式(更省,仍是认证过的)、或要求做 statement v2。我不自行推进。


给放行人的提示:本 PR 命中插件基座(安装链路 + 包格式 / external attributes),按 AGENTS.mddocs/dev-rules/plugin-security-and-authoring.md 第 5 节需走白名单确认门,要放行人针对存量插件影响明确 Approve 后才能合并,不因「是 bugfix / 纯技术改动」豁免。

.cindy 里声明为 0755 的普通文件在 macOS / Linux 安装后变成 0644,插件
无法启动随包的本机可执行程序。文件内容与 SHA-256 一致,只有 mode 丢了。

链路上有三处丢失点:

- extractToStaging() 用 writeFile 落盘,从不读 JSZip 的 unixPermissions;
- forge / export 打包时不写 unixPermissions,且 JSZip 缺省按 DOS 平台生成
  version made by,导致 Cindy 自己打出的 .cindy 压根不带 Unix mode;
- 签名 / 审核会重新生成 central directory,把打包侧刚写入的执行位抹掉。

新增 ghostZipPermissions.ts 作为三侧共用的唯一归一化入口:只采纳归档声明
的权限位(不按扩展名或 bin/ 目录猜执行位),剥除 setuid / setgid / sticky,
额外保底 owner 可读写以免恶意 mode-000 条目卡住后续升级卸载,缺少 Unix
元数据的旧 DOS 包完全不 chmod,Windows 直接跳过。

写盘后用 chmod 而非 writeFile 的 mode 参数:后者会被 umask 掩掉,umask 077
时 0755 会变成 0700,拿不到确定性的权限恢复。

zip-slip、路径穿越、符号链接拒绝与 zip bomb 上限均未改动。存量已装插件不做
权限回补,由重装或升级插件自然恢复。

Signed-off-by: zhanghengxd <128659069+zhanghengxd@users.noreply.github.com>
@zhanghengxd
zhanghengxd requested a review from a team as a code owner August 18, 2026 10:15

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fbb612a66c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/desktop/src/main/cindy-brain/GhostManager.ts
@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown

Greptile Summary

本 PR 为 .cindy 的安装、打包、导出以及签名重打包链路增加 Unix 普通文件权限的保存和恢复,并钳制特殊位及 group/other 写位。

  • 安装时根据 ZIP external attributes 恢复安全化后的普通文件 mode
  • Forge、导出、签名与审核重打包统一写入 UNIX 权限元数据
  • 已签名导出的读后稳定态复验仍未直接比较 mode,读取期间的 chmod 竞态尚未完全关闭

Confidence Score: 4/5

该 PR 在修复已签名导出的权限快照竞态前不宜合并,因为低精度或陈旧 ctime 仍可能让归档写入不同时刻的 mode 与内容。

回复中称同一 FileHandle 已修复竞态,但当前读后判据没有比较 mode;当 chmod 未体现为新的 ctimeNs 时,导出仍会把读前权限与随后读取的字节组合进同一归档条目。

Files Needing Attention: apps/desktop/src/main/cindy-brain/ghostContentTree.ts, apps/desktop/src/main/cindy-brain/exportGhostPackage.ts

Important Files Changed

Filename Overview
apps/desktop/src/main/cindy-brain/ghostContentTree.ts 导出了统一稳定态判据供权限快照复验,但该判据仍未直接比较 mode。
apps/desktop/src/main/cindy-brain/exportGhostPackage.ts 导出现在保存 Unix mode 并复验文件状态,但 ctime 未变化时仍可能组合不同时刻的 mode 与内容。
apps/desktop/src/main/cindy-brain/GhostManager.ts 安装侧在写入普通文件后恢复经过钳制的归档权限,并在 Windows 或缺少 Unix 元数据时跳过 chmod。
apps/desktop/src/main/cindy-brain/ghostZipPermissions.ts 集中实现安装、归档和重打包所需的 Unix 权限解析、类型校验与安全归一化。
apps/desktop/src/main/cindy-brain/ghostSignature.ts 签名和审核重打包改为 UNIX 平台输出,并为每个条目保留安全化后的权限。
apps/desktop/src/main/cindy-brain/forge.ts Forge 打包读取文件快照中的 mode,并将普通权限写入 UNIX ZIP external attributes。

Sequence Diagram

sequenceDiagram
  participant FS as 插件文件
  participant Export as exportGhostPackage
  participant Stable as sameStableFileState
  participant ZIP as .cindy 归档
  Export->>FS: stat(取得 mode)
  Export->>FS: readFile(取得内容)
  Export->>FS: stat(读后复验)
  Export->>Stable: 比较身份、size、mtimeNs、ctimeNs
  Note over Stable: 当前未直接比较 mode
  Stable-->>Export: 稳定
  Export->>ZIP: 写入内容与读前 mode
Loading

Comments Outside Diff (1)

  1. apps/desktop/src/main/cindy-brain/ghostContentTree.ts, line 193-197 (link)

    P1 稳定态判定遗漏 mode

    如果底层文件系统的时间戳精度不足或返回陈旧的 ctimeNs,读取期间的 chmod 不会被当前判据识别,已签名导出便会把读取前的 mode 与随后读取的内容写入同一归档条目,导致执行位错误且不会触发整体重试。

    Context Used: 使用和PR描述相同的语言进行评论 (source)

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/main/cindy-brain/ghostContentTree.ts
    Line: 193-197
    
    Comment:
    **稳定态判定遗漏 mode**
    
    如果底层文件系统的时间戳精度不足或返回陈旧的 `ctimeNs`,读取期间的 `chmod` 不会被当前判据识别,已签名导出便会把读取前的 mode 与随后读取的内容写入同一归档条目,导致执行位错误且不会触发整体重试。
    
    
    
    **Context Used:** 使用和PR描述相同的语言进行评论 ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))
    
    ---
    
    For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Prompt To Fix All With AI
### Issue 1
apps/desktop/src/main/cindy-brain/ghostContentTree.ts:193-197
**稳定态判定遗漏 mode**

如果底层文件系统的时间戳精度不足或返回陈旧的 `ctimeNs`,读取期间的 `chmod` 不会被当前判据识别,已签名导出便会把读取前的 mode 与随后读取的内容写入同一归档条目,导致执行位错误且不会触发整体重试。

```suggestion
  return after.isFile() &&
    sameFileIdentity(before, after) &&
    before.size === after.size &&
    before.mode === after.mode &&
    before.mtimeNs === after.mtimeNs &&
    before.ctimeNs === after.ctimeNs;
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (4): Last reviewed commit: "fix(desktop): 签名包同样恢复归档 mode,并写明该取舍的依据" | Re-trigger Greptile

Comment thread apps/desktop/src/main/cindy-brain/exportGhostPackage.ts Outdated
回应 review 的两处 P1。

装入侧钳掉 group/other 写位:原实现会原样采纳 0777 / 0666,于是被篡改的
包、甚至源文件权限没设好的正常包,都能把 group/world 可写文件装进插件内容
目录,同机其它账号即可改写受害用户随后会执行的插件代码。改前此处恒为 0644,
不存在这个面。0755 / 0644 / 0700 不受影响,0777 收敛为 0755、0666 为 0644。

导出已签名插件时改为同一文件句柄取 stat 与内容:原先 stat 与 readFile 是两
次独立调用,并发 chmod 落在中间会把改动前的 mode 与改动后的内容写进同一归档
条目。内容与 SHA-256 仍然自洽,校验发现不了,导出包的执行位却是旧值。

签名 statement 格式未改动:mode 仍不在签名覆盖范围内,残留风险已在 PR 描述中
向放行人显式申明。

Signed-off-by: zhanghengxd <128659069+zhanghengxd@users.noreply.github.com>
@MagicLizi MagicLizi added awaiting-discussion 等待维护者讨论(review-pr) touches:core 改动碰到架构核心路径(review-pr 自动维护,仅展示) touches:plugin-base 改动碰到插件基座(review-pr 自动维护,仅展示) labels Aug 18, 2026
@MagicLizi

Copy link
Copy Markdown
Contributor

⏸️ 本 PR 触发 pluginBase 维护者确认门。

修改了插件安装与打包时的文件权限处理(保留 Unix 执行位),属插件基座变更,需维护者在 PR 上 Approve 后方可合并。

讨论 issue 已创建,详见上方链接。

讨论 issue:#2956

@zhanghengxd

Copy link
Copy Markdown
Contributor Author

收到,这道 pluginBase 门是预期的 —— 我在 PR 描述末尾也预先标注了本改动命中插件基座、需放行人明确 Approve,不因「是 bugfix」豁免。

issue 里的两个确认项已逐条答复并给了代码依据:#2956 (comment)

摘要:

  1. 已装插件升级 / 重装 —— 不需要重装、不需要重新确认权限、不丢凭证与偏好。关键依据是批准指纹 hashGhostContentFiles()ghostContentTree.ts:371-488)只 hash 版本标签 + 路径 + 文件内容摘要,mode 不参与,所以恢复执行位不会让已批准指纹漂移。升级路径与全新安装共用 extractToStaging()manager.update() 的 mode 恢复有精确断言。如实说明:已经以 0644 装在盘上的插件不会自动回补,需重装或升级该插件才恢复(修复不追溯,非本 PR 引入的退化)。
  2. 旧包格式向下兼容 —— 旧 DOS 包 unixPermissionsnull完全不调 chmod,行为与改动前逐字节一致(有 spy 用例断言)。无新增 manifest 字段、无 schemaVersion 变更。反向也兼容:新包在旧客户端照常安装,只是仍落 0644。另外核过两处容易出事的地方 —— 旧客户端的 isZipSymbolicLink() 不会把新包里的文件误判成符号链接;切 platform: 'UNIX' 也不会让目录条目失去 dir 标记(JSZip 无论平台都置 DOS 目录位,另有名字尾 / 的兜底)。

另外请一并看 PR 描述里的「残留风险」小节:review 阶段 Codex 指出 mode 不在签名覆盖范围内,我已钳掉 group/other 写位堵掉最实际的本机提权路径,但把 mode 绑进签名 statement 属包格式改动(需 v2 + 兼容存量 v1 + 流水线重签),已拆出本 PR。如果你们认为那条残留必须先堵,请 Request Changes,本 PR 可以等那个改动落地后再合。

CI 11 项全绿,含 Windows 两个分片实机通过。等你们判断,我不自行推进。

回应维护者确认门(makecindy#2956)的三条。

已签名 / 已审核包不再采纳归档声明的 mode。签名 statement 只覆盖
(path, sha256, bytes),mode 不在其中,恢复它等于把未认证的 central-directory
元数据当成已签名事实 —— 能篡改包字节的人可以在验签仍然通过的前提下翻转执行位。
未签名包不存在越过签名边界的问题,照常按声明恢复。把归一化 mode 签进 statement
(schemaVersion 2)另行跟进,本次不动包格式。

导出已签名插件时在读取后复验文件稳定态。同一句柄只挡住 open 与 stat 之间的
窗口,chmod 落在读取过程中仍能造出「读前的 mode + 读后的字节」。复用
ghostContentTree 的 sameStableFileState 判据(含 ctime,故覆盖 chmod),不另造
一份:签名文件不一致即抛错,statement 条目不一致返回 null 交给既有整体重试。

Windows 门禁改成正面断言:原先只是在 win32 跳过 mode 断言,现按平台分叉 ——
win32 断言 chmod 从未被调用,非 win32 断言确实调用过,避免这条路径以后静默
退化成「什么都没做」也能绿。

Signed-off-by: zhanghengxd <128659069+zhanghengxd@users.noreply.github.com>
@zhanghengxd

Copy link
Copy Markdown
Contributor Author

维护者确认门(#2956)的三条已全部落地,推到 09ac51a8;详细答复在 issue 里

  • 已签名 / 已审核包不再采纳归档 mode(签名 statement 只覆盖 (path, sha256, bytes),恢复未覆盖的 mode 等于把未认证元数据当成已签名事实);未签名包照常恢复。新增真实签名包用例,断言 chmod 从未被调用、执行位不恢复。
  • 导出读后复验:复用 ghostContentTreesameStableFileState()(含 ctimeNs,覆盖 chmod),不另造判据。
  • Windows 改成正面断言win32 断言不调用 chmod,非 win32 断言确实调用过。

同时更正了 PR 描述里一处 overclaim:新包在旧客户端只保证结构兼容,旧客户端不具备执行位恢复能力,不宣称完整的反向功能兼容。

签名 statement 升 schemaVersion: 2 已拆为 #2966请注意随之而来的取舍:本 PR 落地后,经签名分发的插件仍拿不到执行位,只有未签名包(本地 Forge 产物、旁载)立即受益 —— 原始 bug 对主分发渠道要等 #2966 才算修完。若你们希望一次做到位、本 PR 直接上 v2,请说一声,我改。

验证:desktop typecheck 干净;相关四个测试文件 335/335 通过。如实说明一处 —— pnpm test:unit:related 在我本机红 7 个(claudeOrphanReaper 5 个、ghostInstallReceipt 2 个),但我 checkout 到 upstream/main 复跑同样红这 7 个,属本机环境既有失败、与本改动无关,这批用例在本 PR 前两轮 CI 里均通过。新 commit 的 CI 结果出来我再同步。

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 09ac51a85c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/desktop/src/main/cindy-brain/GhostManager.ts Outdated
撤掉上一轮对已签名 / 已审核包关闭 mode 恢复的分支。那个分支让正式发布的
插件在 macOS / Linux 上依旧启动不了随包程序,只有未签名包受益 —— 等于对主
分发渠道没修。

mode 不在签名覆盖范围内这点不变(statement 只覆盖 path / sha256 / bytes),
但在 installedFileModeFromZip 的钳位之后,能篡改包字节的攻击者只剩「翻转
r / x 位」:内容改不了、文件增删不了(全量白名单 + 逐文件哈希 + canonical
全等)、特殊位剥掉、group/other 写位钳掉、owner 读写强制保留。去掉 +x 只是
他本来就能造成的可用性破坏(改坏一字节即验签失败);加上 +x 作用在他无法
选择内容的文件上,而执行流指向哪个文件由签名覆盖的 manifest 与插件自身代码
决定,拿不到代码执行。

该取舍连同失效条件("若日后有逻辑开始依赖 mode 做安全判断,须把归一化 mode
签进 statement")写进 extractToStaging 的注释,并由用例钉住:签名包与未签名
包走同一条恢复路径,0755 / 0644 保留、特殊位剥除。改成"签名包不采纳 mode"
会让该用例红 —— 这是产品/安全决定,不该被静默改掉。

上一轮的另外两项保留:导出读后复验文件稳定态、Windows 按平台正面断言。

Signed-off-by: zhanghengxd <128659069+zhanghengxd@users.noreply.github.com>
@zhanghengxd

Copy link
Copy Markdown
Contributor Author

b29c750b撤回上一轮「已签名 / 已审核包不采纳归档 mode」的分支,签名包与未签名包统一恢复 mode。

撤回的理由正是 Codex 复审指出的那条:那个分支形式上满足了「不把未认证 mode 当已签名事实」,但代价是正式发布的插件在 macOS / Linux 上依旧启动不了随包程序,只有未签名包受益 —— 对主分发渠道等于没修。用一个安全但功能为空的分支换掉 bug 修复本身不是好交易。

取而代之,把维护者确认门(#2956)点名要的那份依据补上了 —— 「现有证据不足以默认做出这一产品/安全决定」,那句我理解为「请给依据再判」。残留风险实算:钳位之后攻击者改不了任何文件内容、增删不了文件buildStatement() 是全量白名单 + 逐文件 sha256 + canonical 全等)、设不了特殊位装不出 group/world 可写文件剥不掉 owner 读写;剩下的只有在内容已被哈希钉死的文件上翻转 r / x 位。去掉 +x 只是他本来就能造成的可用性破坏(改坏一字节即验签失败);加上 +x 作用在他无法选择内容的文件上,而执行流指向哪个文件由签名覆盖的 manifest 与插件自身代码决定 —— 拿不到代码执行。完整逐条分析在 #2956

失效条件写进代码了,不只在 PR 描述里:extractToStaging() 的注释写明「若日后有任何逻辑开始依赖 mode 做安全判断,该前提即失效,届时必须把归一化 mode 纳入认证」。并由用例钉住 —— 签名包与未签名包走同一条恢复路径,若有人改成「签名包不采纳 mode」,那条用例会红。

同时在 #2956 里给了一条更省的备选:真要认证 mode 也不必改签名格式 —— ghost.json 本来就在签名覆盖范围内,在 manifest 里声明「哪些文件需要执行位」那份声明天然就被签名认证,不需要 v2 的双轨验签与流水线重签。维护者若倾向这条,说一声我改。

#2966(schema v2 跟进)已按此决定关闭,理由记在那个 issue 里。

上一轮另外两项保留不变:导出已签名插件时读后复验文件稳定态(复用 sameStableFileState())、Windows 按平台正面断言(win32 断言不调用 chmod,非 win32 断言确实调用过)。

验证:desktop typecheck 干净;相关四个测试文件 335/335 通过。仍如实说明 —— pnpm test:unit:related 在我本机红 7 个(claudeOrphanReaper 5、ghostInstallReceipt 2),checkout 到 upstream/main 复跑同样红这 7 个,属本机环境既有失败、与本改动无关,这批在本 PR 前几轮 CI 里均通过。以 CI 为准。

@MagicLizi MagicLizi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

维护者确认(插件基座门)与审查结论:

存量插件兼容:确认无影响——指纹哈希不含 mode,批准状态/布局/包格式契约零改动,DOS 旧包逐字节原行为(spy 断言零 chmod),已装插件无需重装或重新确认。

安全取舍裁定(#2956 遗留):接受签名包与未签名包统一恢复归档 mode。前提已逐条核实:钳位后篡改者改不了内容、增删不了文件(buildStatement 全量白名单+逐文件 sha256)、设不了 setuid/setgid/sticky、装不出 group/world 可写文件;残留仅为原有 DoS + 在无法选择内容的文件上翻转 r/x 位,拿不到代码执行。失效条件已写进 extractToStaging 注释并有签名包用例钉住。manifest 声明式与 statement v2 作为备选已记录在讨论中,不阻塞本 PR。

审查:三处丢失点修复完整;导出读后复验(sameStableFileState 含 ctimeNs)关闭快照竞态;win32/非 win32 按平台分叉真断言;diff 与描述一致,无夹带。CI 11/11 全绿(含 Windows 分片),DCO 完整。没有 P0/P1。

@MagicLizi

Copy link
Copy Markdown
Contributor

维护者确认已通过(pluginBase / arch),PR 恢复正常推进。

@MagicLizi MagicLizi removed the awaiting-discussion 等待维护者讨论(review-pr) label Aug 18, 2026
@MagicLizi
MagicLizi merged commit 8e8a3ae into makecindy:main Aug 18, 2026
13 checks passed
@MagicLizi

Copy link
Copy Markdown
Contributor

合了。从三处丢失点的排查、把每条排除掉的怀疑都记录在案,到把「mode 要不要进签名边界」的风险实算清楚再交给放行人裁定——这轮迭代把该想的都想透了。以后 macOS/Linux 上装带本机程序的插件,终于不用用户手动 chmod 了,谢谢。

@zhanghengxd
zhanghengxd deleted the fix/ghost-installer-unix-mode branch August 19, 2026 02:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

touches:core 改动碰到架构核心路径(review-pr 自动维护,仅展示) touches:plugin-base 改动碰到插件基座(review-pr 自动维护,仅展示)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants