## 密钥泄漏闸 把活交给 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>
242 lines
10 KiB
Swift
242 lines
10 KiB
Swift
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)
|
||
}
|
||
}
|