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

285 lines
11 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 HubIPC
/// 一条待决策的审批请求。
public struct ApprovalRequest: Identifiable, Sendable {
public let id: String
public let sessionId: String
public let projectName: String
public let cwd: String
public let toolName: String
public let command: String
public let risk: RiskLevel
public let createdAt: Date
/// 发起这次调用的 hook 进程。用来检测对端是否还活着,见
/// `ApprovalCoordinator.startOrphanSweep()`。
public let clientPid: pid_t
public init(
id: String, sessionId: String, projectName: String, cwd: String,
toolName: String, command: String, risk: RiskLevel,
clientPid: pid_t = 0, createdAt: Date = Date()
) {
self.id = id
self.sessionId = sessionId
self.projectName = projectName
self.cwd = cwd
self.toolName = toolName
self.command = command
self.risk = risk
self.clientPid = clientPid
self.createdAt = createdAt
}
/// L3 的「允许」需要按住确认,L2 只需要等解锁。
public var requiresHoldToConfirm: Bool { risk == .irreversible }
}
/// 审批日志的一条记录。这份数据现在完全没留存,但它很值钱 ——
/// 用户看一周就知道该不该把 `dangerous` 一档也开启拦截。
public struct ApprovalLogEntry: Codable, Identifiable, Sendable {
public let id: String
public let timestamp: Date
public let projectName: String
public let toolName: String
public let command: String
public let risk: RiskLevel
public let verdict: String
/// 是否真的弹了岛。`dangerous` 默认只记录不拦截,这时是 false。
public let intercepted: Bool
}
/// 岛上审批的协调器。
///
/// hook 线程会在 `await` 上阻塞直到用户点击或超时,所以这里用 continuation
/// 而不是轮询 —— hook 那边每多等 100ms 都是用户在等。
@Observable
@MainActor
public final class ApprovalCoordinator {
/// 当前排队中的请求。第一条显示在岛上,其余用堆叠卡片暗示。
public private(set) var queue: [ApprovalRequest] = []
public private(set) var log: [ApprovalLogEntry] = []
/// 拦截门槛。默认只拦不可逆操作,见 RiskClassifier 里的理由。
public var interceptThreshold: RiskLevel = .irreversible
private var pending: [String: CheckedContinuation<HookDecision, Never>] = [:]
private var timeoutTasks: [String: Task<Void, Never>] = [:]
/// 用户决策的时限。到点自动拒绝。
///
/// 默认 deny 而不是 allow:用户全局开着 auto + skip-permissions,这是唯一的刹车。
/// 而且 deny 是可恢复的(Claude 会告诉用户被拒了,重跑即可),allow 不可逆。
///
/// 这是超时阶梯里**最内层**的一环,见 `HookTimeouts`。
private let timeout = HookTimeouts.userDecision
private var orphanSweep: Task<Void, Never>?
/// 审批日志的落盘位置。
///
/// 这份数据的**唯一用途**就是"看一周再决定要不要把 dangerous 一档也拦上"——
/// 只存在内存里的话 app 一重启就清零,这个功能等于不存在。
public static var defaultLogURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/claude-hub/approval-log.json")
}
/// nil = 不落盘。
///
/// **必须可注入。** 把路径写死在类型里会让测试直接读写用户的真实日志:
/// 测试之间互相污染是小事,往用户数据里写测试垃圾是真问题。
private let logURL: URL?
public init(logURL: URL? = defaultLogURL) {
self.logURL = logURL
log = Self.loadLog(from: logURL)
}
private static func loadLog(from url: URL?) -> [ApprovalLogEntry] {
guard let url,
let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([ApprovalLogEntry].self, from: data)
else { return [] }
return decoded
}
/// 每次追加都落盘。
///
/// 频率极低(只有被拦截或被记录的高风险操作才走到这里),
/// 而"崩溃/退出后丢掉最后几条"恰恰是最不能接受的 —— 用户想看的就是
/// 刚才那次到底发生了什么。所以不做批量延迟写。
private func persistLog() {
guard let logURL else { return }
try? FileManager.default.createDirectory(
at: logURL.deletingLastPathComponent(), withIntermediateDirectories: true
)
guard let data = try? JSONEncoder().encode(log) else { return }
try? data.write(to: logURL, options: .atomic)
}
public var current: ApprovalRequest? { queue.first }
// MARK: - 僵尸请求清理
/// 定期清掉「发起方已经死了」的审批卡。
///
/// 真实场景:Claude 被 Ctrl-C、会话被关、hook 进程超时被杀 —— 这些情况下
/// hubctl 没了,但服务端仍然阻塞在等决策上,岛上那张卡会一直挂着。
/// 用户要么被迫点一下一个**早已无人接收**的决策,要么它会一直挡住
/// 后面真正需要处理的请求(队列是先进先出的)。
///
/// 判据用 `HookEvent.clientPid` —— 这个字段本来就一路传过来了。
/// 判 deny 而不是 allow:反正没人收,但万一对端还活着只是暂时不可见,
/// 拒绝是可恢复的那一侧。
public func startOrphanSweep() {
orphanSweep?.cancel()
orphanSweep = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
await self?.dropOrphans()
}
}
}
public func stopOrphanSweep() {
orphanSweep?.cancel()
orphanSweep = nil
}
private func dropOrphans() {
for request in queue where request.clientPid > 0 {
// kill(pid, 0):EPERM 说明进程存在但不属于我们,也算活着。
guard kill(request.clientPid, 0) != 0, errno == ESRCH else { continue }
resolve(
id: request.id,
decision: HookDecision(verdict: .deny, reason: "发起方已退出"),
verdictLabel: "orphaned"
)
}
}
// MARK: - hook 侧入口
/// 提交一次审批请求并等待结论。**会阻塞调用方直到有结果。**
public func requestDecision(for request: ApprovalRequest) async -> HookDecision {
queue.append(request)
let task = Task { [weak self] in
try? await Task.sleep(for: .seconds(self?.timeout ?? 60))
guard !Task.isCancelled else { return }
await self?.resolve(
id: request.id,
decision: HookDecision(verdict: .deny, reason: "Vibe Foreman:60 秒未响应,已自动拒绝"),
verdictLabel: "timeout"
)
}
timeoutTasks[request.id] = task
return await withCheckedContinuation { continuation in
pending[request.id] = continuation
}
}
// MARK: - UI 侧动作
public func allow(_ request: ApprovalRequest) {
resolve(
id: request.id,
decision: HookDecision(verdict: .allow),
verdictLabel: "allow"
)
}
public func deny(_ request: ApprovalRequest) {
resolve(
id: request.id,
decision: HookDecision(verdict: .deny, reason: "用户在 Vibe Foreman 上拒绝了这次操作"),
verdictLabel: "deny"
)
}
/// 跳过:把当前这条挪到队尾,先处理别的。**不代表拒绝。**
public func skip(_ request: ApprovalRequest) {
guard queue.count > 1, let index = queue.firstIndex(where: { $0.id == request.id })
else { return }
let item = queue.remove(at: index)
queue.append(item)
}
private func resolve(id: String, decision: HookDecision, verdictLabel: String) {
timeoutTasks[id]?.cancel()
timeoutTasks[id] = nil
guard let continuation = pending.removeValue(forKey: id) else { return }
let request = queue.first { $0.id == id }
queue.removeAll { $0.id == id }
continuation.resume(returning: decision)
if let request {
appendLog(request: request, verdict: verdictLabel, intercepted: true)
}
}
/// 记一笔没有拦截的高风险操作(`dangerous` 档默认走这里)。
public func recordWithoutIntercepting(
projectName: String, toolName: String, command: String, risk: RiskLevel
) {
log.insert(
ApprovalLogEntry(
id: UUID().uuidString, timestamp: Date(), projectName: projectName,
toolName: toolName, command: command, risk: risk,
verdict: "auto-allow", intercepted: false
),
at: 0
)
trimLog()
}
/// 密钥泄漏闸拦下的一次调用。
///
/// 和别的日志条目不同:这条**不是这里决策的** —— hubctl 在本地就拒了,
/// 我们只是把它记下来。所以 verdict 写死 `guard-deny`,
/// 不要拿它当"审批流程走过一遍"的证据。
public func recordSecretGuardBlock(
projectName: String, toolName: String, command: String, reason: String
) {
log.insert(
ApprovalLogEntry(
id: UUID().uuidString, timestamp: Date(), projectName: projectName,
toolName: toolName,
// 命令本身可能什么都看不出来(比如 Write 只带文件名),
// 把拦截理由拼上去,日志里一眼能看懂为什么被拦。
command: command.isEmpty ? reason : "\(command)\n\(reason)",
risk: .irreversible, verdict: "guard-deny", intercepted: true
),
at: 0
)
trimLog()
}
private func appendLog(request: ApprovalRequest, verdict: String, intercepted: Bool) {
log.insert(
ApprovalLogEntry(
id: request.id, timestamp: Date(), projectName: request.projectName,
toolName: request.toolName, command: request.command, risk: request.risk,
verdict: verdict, intercepted: intercepted
),
at: 0
)
trimLog()
}
private func trimLog() {
if log.count > 500 { log.removeLast(log.count - 500) }
persistLog()
}
/// 清空审批日志(设置页用)。
public func clearLog() {
log = []
persistLog()
}
}