Files
wuguanganandClaude Opus 5 e3b9153b66 feat: 拦住 AI 把密钥搬出去;README 补 AI 时代的隐私那一节
## 密钥泄漏闸

把活交给 agent 之后,泄密的形态变了。以前是人手滑,现在是**每一步都很合理的
一串操作**:git add . 顺手把 .env 提了、把真实 token 内联进配置文件当"示例"、
为了确认配置 cat 一下密钥(值就进了 transcript,0644、永久、每轮重发给 API)。

**没有一条能靠「这条命令危不危险」判断出来。** 它们都不危险。唯一可靠的信号是
这次调用的内容里有没有出现真实的密钥值。

所以加了一道闸,默认开:参数里出现库里的真实值 → 拒;git add/commit 会把
.env 一类文件或含真实值的文件带进仓库 → 拒。拒绝理由里写明正确做法
(用 $KEY、先加 .gitignore),否则模型只会换个写法再试一次。

**它跑在 hubctl 本地,不依赖 app 在不在跑。** 仓库其它拦截都是「app 没开就放行」
(一个监控工具不该让你的命令跑不起来),这一条反过来 —— 密钥漏出去不可逆。

几条守住的边界,判宽了比判窄了危险:值短于 8 字符不参与匹配(abc/true/test
会命中一切);.example/.template/.sample 结尾放行(模板文件本来就该提交,
误拦一次用户就把整个功能关掉了);只认 EnvRenderer 写出来的格式,不做通用
dotenv 解析;不是 git add/commit 就一个子进程都不起。

拒了之后事件照发给 app(不等应答),进审批日志 —— 一道看不见的闸,
用户第一次被拦时只会觉得 Claude 抽风。开关在密钥页底部,
落成 ~/.vibe-foreman/guard.json,且**只在关掉时才写**:
默认开,「文件不存在 = 开着」,这样没打开过密钥页的人也受保护。

明确写了它挡不住什么:python -c 读、软链、base64 转一道、库外手写的值。
它是一道减少手滑的闸,不是沙箱。

## 截图

主窗口现在也能自动截了:HUB_WINDOW_SECTION 钉住打开哪一页,
配 DemoFixtures.seed 造的假密钥和假账号(刻意编得一眼假 —— 一个像真的
token 混进去,读者会去试)。

两个坑:find-window 按面积取第一个会截到岛(它比主窗口先出现一瞬),
改成按宽度 ≥900 筛;.accessory 的进程从后台 shell 起来抢不到焦点,
而非活跃窗口里的系统控件会被去饱和 —— 截出来开关全是灰的,
正好把要展示的东西说反了,所以截图模式下改用 .regular。

顺带:进密钥页自动选中第一组。有组却什么都不选,右边一大片空白,
那是"还没建组"的样子而不是"有四组等你挑"的样子。

646 个测试全过(原 625)。真 hubctl 二进制端到端验过四种情况:
写入真值被拒、约定的 source 用法放行、git add . 带 .env 被拒、普通命令放行。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:46:46 +08:00

242 lines
10 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 Foundation
import Observation
/// 共用密钥库。
///
/// 权威副本是**加密**的(`shared-secrets.dat`),明文只存在于
/// `~/.vibe-foreman/env/by-project/*.env` 那几个物化出来的文件里。
///
/// 加密权威副本不是洁癖:不加密的话,`~/Library/Application Support/claude-hub/`
/// 里会躺着一个含**全部**密钥的明文 JSON,AI 一条 `Read` 就整包拿走 ——
/// 那比按项目分装的那些文件糟糕得多,而且用户完全不会想到那里有东西。
/// 加密之后,关掉「物化到磁盘」这个开关就真的等于磁盘上一份明文都没有。
@Observable
@MainActor
public final class SharedSecretStore {
public private(set) var groups: [SecretGroup] = []
public private(set) var state: VaultLoadState = .empty
/// 上一次物化的结果。界面要显示出来 —— 写不进去却不吭声,
/// 用户会拿着一个内容早就过期的路径去用。
public private(set) var lastOutcome: EnvMaterializer.Outcome = .alreadyCorrect
/// 总开关。关掉时清空整个物化目录。
public var materializeEnabled: Bool = true {
didSet {
guard materializeEnabled != oldValue else { return }
persist()
reconcile()
}
}
/// 密钥泄漏闸。**默认开。**
///
/// 真正读它的是 hubctl,通过 `~/.vibe-foreman/guard.json` ——
/// 那个进程每次工具调用都要跑一遍,不可能去解密库里的设置。
/// 所以这里改完要立刻落成那个文件。
public var leakGuardEnabled: Bool = true {
didSet {
guard leakGuardEnabled != oldValue else { return }
persist()
writeGuardConfig()
}
}
@ObservationIgnored private let file: EncryptedFile<Payload>
@ObservationIgnored private let root: URL
/// 上一次拿到的项目列表。物化需要项目名(拼文件名)和 key(查绑定)。
@ObservationIgnored private var knownProjects: [Project] = []
private struct Payload: Codable, Sendable {
var data: SecretVaultData
var materializeEnabled: Bool
/// 老版本的密文里没有这个字段,解出来当 true —— 默认开。
var leakGuardEnabled: Bool?
}
public nonisolated static var defaultURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/claude-hub/shared-secrets.dat")
}
/// - Parameters:
/// - url: nil = 不落盘。同仓库里其它 store:路径写死会让测试改用户真实数据。
/// - root: 物化的根目录。
/// - keys: 主密钥来源。测试传 `.fixed(...)`,别去碰真钥匙串。
public init(
url: URL? = SharedSecretStore.defaultURL,
root: URL = EnvMaterializer.defaultRoot,
keys: VaultKeySource = .keychain
) {
self.file = EncryptedFile<Payload>(url: url, context: "shared.v1", keys: keys)
self.root = root
if let payload = file.load() {
groups = payload.data.groups
// 绕开 didSet:加载不是「用户改了开关」,不该触发回写和重新物化。
_materializeEnabled = payload.materializeEnabled
_leakGuardEnabled = payload.leakGuardEnabled ?? true
}
state = file.state
// 启动时对一次 guard.json:用户可能手改过它,也可能上次没写成。
// hubctl 读的是那个文件,两边不一致的话界面上的开关就是假的。
writeGuardConfig()
}
// MARK: - 查
public func groups(for project: Project) -> [SecretGroup] {
SecretVaultData(groups: groups).groups(forProjectKey: ProjectKey.key(for: project))
}
public func isBound(_ group: SecretGroup, to project: Project) -> Bool {
group.projectKeys.contains(ProjectKey.key(for: project))
}
/// 用户要复制的那个路径。
public func envFilePath(for project: Project) -> String {
EnvMaterializer.byProjectDirectory(root: root)
.appendingPathComponent(ProjectKey.envFileName(for: project)).path
}
/// 「复制给 AI 的话」按钮的内容。
///
/// **不是裸路径。** 裸路径的下场是 AI 用 `Read` 打开它,于是密钥进
/// `~/.claude/projects/*.jsonl`(0644、永久、每轮重发),
/// 而且 Hub 自己的验收清单(`Evidence.ran` 的输出尾巴)也会把它抄一份存下来。
/// `source` 则只让路径和变量名出现在 transcript 里,值一次都不出现。
/// 这两句用法和禁令,是「复制路径给 AI」这个交付方式真正管用的版本。
public func aiPrompt(for project: Project) -> String {
"""
这个项目要用的平台密钥在 \(envFilePath(for: project))。
用法:在需要它的那条命令里 `set -a; source '\(envFilePath(for: project))'; set +a; <你的命令>`。
不要用 Read / cat / grep 打开它,也不要把里面的值写进任何文件或回复。
"""
}
public var materializedFileCount: Int {
EnvMaterializer.materializedFileCount(root: root)
}
// MARK: - 改
@discardableResult
public func addGroup(name: String, note: String = "") -> SecretGroup {
let group = SecretGroup(name: name, note: note)
groups.append(group)
commit()
return group
}
public func update(_ group: SecretGroup) {
guard let index = groups.firstIndex(where: { $0.id == group.id }) else { return }
groups[index] = group
commit()
}
public func removeGroup(id: UUID) {
groups.removeAll { $0.id == id }
commit()
}
public func addEntry(to groupID: UUID, key: String, value: String, note: String = "") {
guard let index = groups.firstIndex(where: { $0.id == groupID }) else { return }
groups[index].entries.append(SecretEntry(key: key, value: value, note: note))
commit()
}
public func update(entry: SecretEntry, in groupID: UUID) {
guard let g = groups.firstIndex(where: { $0.id == groupID }),
let e = groups[g].entries.firstIndex(where: { $0.id == entry.id }) else { return }
groups[g].entries[e] = entry
commit()
}
public func removeEntry(id: UUID, from groupID: UUID) {
guard let index = groups.firstIndex(where: { $0.id == groupID }) else { return }
groups[index].entries.removeAll { $0.id == id }
commit()
}
public func setBinding(groupID: UUID, project: Project, bound: Bool) {
guard let index = groups.firstIndex(where: { $0.id == groupID }) else { return }
let key = ProjectKey.key(for: project)
if bound { groups[index].projectKeys.insert(key) } else { groups[index].projectKeys.remove(key) }
commit()
}
// MARK: - 物化
/// 项目列表变了就调一次。物化要靠它拿项目名(拼文件名)和 key(查绑定)。
public func refresh(projects: [Project]) {
knownProjects = projects
reconcile()
}
private func commit() {
persist()
reconcile()
}
private func persist() {
guard file.save(Payload(data: SecretVaultData(groups: groups),
materializeEnabled: materializeEnabled,
leakGuardEnabled: leakGuardEnabled)) else {
state = file.state
return
}
state = file.state
}
/// 把闸的开关落成 hubctl 读得到的那个文件。
///
/// **只在关掉时才写文件。** 默认开,而"文件不存在 = 开着"是 hubctl 那边的约定 ——
/// 这样一个从没打开过密钥页的用户也受保护,而且不会凭空多出一个
/// `~/.vibe-foreman/`(那个目录本来是只有用了共用密钥才该出现的)。
private func writeGuardConfig() {
let url = root.appendingPathComponent("guard.json")
guard !leakGuardEnabled else {
try? FileManager.default.removeItem(at: url)
return
}
try? FileManager.default.createDirectory(
at: root, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
try? Data(#"{"enabled":false}"#.utf8).write(to: url, options: .atomic)
}
private func reconcile() {
// **锁死/坏文件状态下一个文件都不许碰。**
//
// 这时候 `groups` 是空的,但那是「读不出来」不是「没有」。
// 照常 reconcile 的话,会把用户磁盘上那几份密钥当成「已解绑」全删掉 ——
// 一次钥匙串抽风就赔进去所有物化文件。
guard state.canWrite else { return }
let data = SecretVaultData(groups: groups)
let byKey = Dictionary(knownProjects.map { (ProjectKey.key(for: $0), $0) },
uniquingKeysWith: { first, _ in first })
var targets: [EnvMaterializer.Target] = []
for key in data.boundProjectKeys.sorted() {
// 项目从 projects.yaml 里删掉了,但绑定还在。
// 不生成文件(没有项目名就没法拼稳定的文件名),旧文件会被 reconcile 清掉 ——
// 这正是我们要的:项目没了,它的密钥也不该继续躺在磁盘上。
guard let project = byKey[key] else { continue }
let fileName = ProjectKey.envFileName(for: project)
let path = EnvMaterializer.byProjectDirectory(root: root)
.appendingPathComponent(fileName).path
targets.append(.init(
fileName: fileName,
contents: EnvRenderer.render(
groups: data.groups(forProjectKey: key),
projectName: project.name,
filePath: path
)
))
}
lastOutcome = EnvMaterializer.reconcile(targets: targets, root: root,
enabled: materializeEnabled)
}
}