Files
wuguanganandClaude Opus 5 a9363e43c9 feat: 共用密钥中心 + 项目账号密码库
两个功能来自同一个痛点,但威胁模型正好相反,这是整个设计的支点。

**共用密钥**:平台密钥(飞书、领星、OpenAI)在十几个项目里要各配一遍,
而最省事的做法——直接粘进聊天框告诉 AI——是最糟的那个。现在按平台建组、
勾给项目,物化成 ~/.vibe-foreman/env/by-project/<项目>-<哈希>.env
(目录 0700、文件 0600),复制路径给 AI。这份数据**故意是明文、故意给 AI 用**。

**账号密码**:本地/测试/线上分开记,只给人看。AES-GCM 加密,
查看和复制过 Touch ID。这份数据**绝不能被任何自动化读到**。

两者不共用存储、不共用目录、不共用界面,并用一条哨兵测试钉死
「密码的值不会出现在给 AI 的那个目录里」。

三个决定了做法的发现:

1. 「让 AI Read 那个文件」会把密钥写进两个明文文件——
   ~/.claude/projects/*.jsonl(0644、永久、每轮重发给 API),
   以及 Hub 自己的验收清单(Evidence.ran 会存命令和输出尾巴,同样不过期)。
   所以「复制给 AI」给的不是裸路径,是带 `set -a; source` 用法和
   「不要用 Read/cat/grep」禁令的三句话。换成 source 之后,
   transcript 里只剩路径和变量名,值一次都不出现。

2. 按项目分文件**是分装不是隔离**——同 uid 下 cat ../别的.env 照样拿得到。
   README 和代码注释都按分装写,不吹成边界。

3. Keychain 的授权弹窗**就是那道防线**。SecItemAdd 不传 kSecAttrAccess 时,
   默认 ACL 只允许创建它的 app 无提示读取,而在「攻击者 = 同 uid 的自动化」
   这个模型下,能区分「Hub 在读」和「security 命令在读」的只有它——
   文件权限做不到(同 uid),Touch ID 也做不到(LAContext 不参与取密钥)。
   为了消弹窗放宽 ACL,等于一条 Bash 就能取走主密钥。

几个不写下来一定会再踩的坑(已进 NOTES.md):
- .atomic 写换 inode,chmod 必须每次都做,只在创建时设一次从第二次写起就是 0644
- GCM 的 AAD 必须用文件里存下来的那段字节,重新序列化会让历史文件永久打不开
- 「钥匙不对」和「文件坏了」处置完全相反,靠 header 里的 keyID 分辨;
  EncryptedFile.save() 的 guard state.canWrite 是这套区分唯一的落地点
- LAContext 复用才有 reuse duration,每次新建等于每条密码按一次指纹
- 剪贴板定时清空必须比对 changeCount,否则用户复制的别的东西会被莫名清掉
- .env 的引号:shell / node dotenv / python-dotenv 三家的交集只有单引号;
  多行值用字面换行不能用 \n;含单引号的值没有共同解,改写成 _B64 显式降级

617 个测试全过(原 530)。真 bash `set -a; source` 验过含空格、$、反引号、
#、多行 PEM 和含单引号的值。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:45:20 +08:00

76 lines
3.4 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import HubProjects
/// 项目行的菜单内容。
///
/// **岛和主窗口共用这一份。** 两边各写一遍的话必然漂移 —— 实际上已经漂过:
/// 岛的菜单里有「接着上次」,主窗口的没有,同一个项目在两个界面上能做的事
/// 不一样,而用户没有任何线索知道为什么。
///
/// 三个入口都走这里:右键、"…" 按钮、主窗口的 ▶ 按钮。
@MainActor
enum ProjectMenu {
/// - Parameter resumable: 上一个被「关窗即结束」收掉、且接得回来的会话。
/// 有它就排第一 —— 关掉窗口后回来的人想接着往下走,
/// 不是从空白开一个新的。
static func items(
isPinned: Bool,
resumable: ClosedSession?,
/// 目录已经不在了。这时候不该给"开项目"的选项 —— tmux 对不存在的
/// `-c` 目录返回 0 并静默回落到家目录,点了只会开出一个开在家目录的
/// claude,然后用户以为是菜单坏了。只留「从列表移除」。
isMissing: Bool = false,
onLaunch: @escaping (LaunchMode) -> Void,
onTogglePin: @escaping () -> Void,
onRemove: (() -> Void)? = nil,
/// 这个项目绑了共用密钥时才有。给的是路径,不是值。
///
/// 只做「复制路径」不做「跳到密钥页」:岛和主窗口都得有同一项,
/// 而"跳到某一页"在岛上没有对应物。复制路径两边都成立,菜单也就不会漂。
onCopySecretPath: (() -> Void)? = nil
) -> [RowMenu.Item?] {
var items: [RowMenu.Item?] = []
// 目录没了就只给「移除」这一条。
//
// **一个都不留是刻意的**:这里能放的每一项(开 Claude、开终端、
// 开 Finder)在目录不存在时全都是"点了没反应"或者"开到家目录",
// 而这次事故的全部内容就是这些静默失败。
// 给一个假的可点项,等于把刚修好的问题又造一遍。
if isMissing {
items.append(RowMenu.Item("⚠︎ 目录已不存在,无法打开", action: {}))
items.append(nil)
if let onRemove {
items.append(RowMenu.Item("从列表移除", action: onRemove))
}
return items
}
if let resumable {
items.append(RowMenu.Item(
"接着上次(\(RelativeTime.short(from: resumable.endedAt))前结束)"
) { onLaunch(.resumeSession) })
items.append(nil)
}
// `.resumeSession` 不进这一段:它要一个具体的 sessionId,
// 而那个 id 只有上面那条「接着上次」知道。菜单里再放一个同名项
// 只会退化成 `claude --resume` 的交互列表。
items += LaunchMode.allCases
.filter { $0 != .resumeSession }
.map { mode in RowMenu.Item(mode.label) { onLaunch(mode) } }
if let onCopySecretPath {
items.append(nil)
items.append(RowMenu.Item("复制密钥路径", action: onCopySecretPath))
}
items.append(nil)
items.append(RowMenu.Item(isPinned ? "取消置顶" : "置顶", action: onTogglePin))
if let onRemove {
items.append(RowMenu.Item("从列表移除", action: onRemove))
}
return items
}
}