两个功能来自同一个痛点,但威胁模型正好相反,这是整个设计的支点。 **共用密钥**:平台密钥(飞书、领星、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>
76 lines
3.4 KiB
Swift
76 lines
3.4 KiB
Swift
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
|
||
}
|
||
}
|