From a5ba3f7573a3ef3d4e1ad813dd602fa1475155b7 Mon Sep 17 00:00:00 2001 From: wuguangan Date: Fri, 7 Aug 2026 19:37:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Hub=20=E8=87=AA=E5=B7=B1=E8=B7=91?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E5=91=BD=E4=BB=A4=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E5=8A=9F=E8=83=BD=E8=AF=81=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阶段 4 的教训直接催生了这一步:diff 复核把 `case ran(command:exitCode:)` 这个**枚举声明**当成了功能已实现,而没有任何代码产生它。读代码判断不了 "功能是否真的能用",只有跑一遍能。 执行者必须是 Hub 而不是 Claude —— 让它跑完再转述,就又回到了「说说而已」, 转述那一步正是失真发生的地方。产出 Evidence.ran(isProof = true)。 三道闸,全部有测试,元字符那道做过变异验证: 1. 白名单:只放行构建/测试类。刻意排除 npx / pnpm dlx(会下载执行任意包, 放行它等于白名单形同虚设)、npm install(跑 postinstall 等于执行任意 代码)、git 的写操作。bash 限定在项目的 scripts/ 下。 2. 元字符:16 个字符逐个测。**这道检查必须在白名单之前** —— 变异验证时把它挪到后面,`swift test | tee /tmp/x` 立刻因为前缀匹配上 `swift test` 而被放行。配合 Shell.run 走 execve 数组参数(不是 sh -c)。 3. 总开关默认 off + 逐条授权。授权按命令原文精确匹配,改一个字要重新授权; 而且手工往 authorized 里塞危险命令也绕不过前两道(单独一条测试守)。 顺带修了注入正文太长:真实项目上拦了 12 条带完整验收条件,糊了满屏。 要点一多就没人看,反而比不拦更容易被忽略。上限 6 条,存疑的排前面, 省略了多少明确说出来。 Shell.run 加 currentDirectory —— 验收命令必须在项目目录里跑, 否则 swift test 找不到 Package.swift。 388 个测试。 Co-Authored-By: Claude Opus 5 (1M context) --- HubKit/Sources/ClaudeHubApp/main.swift | 15 ++ HubKit/Sources/HubCore/Acceptance.swift | 10 + .../Sources/HubProbe/AcceptanceVerifier.swift | 254 ++++++++++++++++++ HubKit/Sources/HubProbe/Shell.swift | 9 +- HubKit/Sources/HubUI/AcceptancePane.swift | 89 ++++++ HubKit/Sources/HubUI/AcceptanceStore.swift | 46 +++- HubKit/Sources/HubUI/MainWindow.swift | 41 ++- HubKit/Sources/HubUI/VerifierSettings.swift | 65 +++++ .../AcceptanceVerifierTests.swift | 246 +++++++++++++++++ .../HubUITests/AcceptanceStoreTests.swift | 39 +++ 10 files changed, 810 insertions(+), 4 deletions(-) create mode 100644 HubKit/Sources/HubProbe/AcceptanceVerifier.swift create mode 100644 HubKit/Sources/HubUI/VerifierSettings.swift create mode 100644 HubKit/Tests/HubProbeTests/AcceptanceVerifierTests.swift diff --git a/HubKit/Sources/ClaudeHubApp/main.swift b/HubKit/Sources/ClaudeHubApp/main.swift index 9eefd5e..d77f7dd 100644 --- a/HubKit/Sources/ClaudeHubApp/main.swift +++ b/HubKit/Sources/ClaudeHubApp/main.swift @@ -27,6 +27,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let notifications = HubNotificationCenter() private let placement = IslandPlacementStore() private let acceptance = AcceptanceStore() + private let verifierSettings = VerifierSettings() + /// 唯一会执行命令的组件。默认关着,由 `verifierSettings` 驱动。 + private let verifier = AcceptanceVerifier() private lazy var island = IslandController( store: store, approvals: approvals, prompts: prompts, projects: projects, @@ -51,6 +54,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // 必须在任何一次读取之前把合成数据写到盘上。 if DemoFixtures.isEnabled { DemoFixtures.materialize() } + // 总开关 → verifier actor。 + // 不接的话用户在设置里打开了开关,verifier 那边还是关的 —— 一个默默 + // 不生效的开关比没有开关更糟。启动时也要同步一次,不能只等变化。 + verifierSettings.onChange = { [weak self] enabled in + guard let self else { return } + Task { await self.verifier.update(configuration: .init(enabled: enabled)) } + } + let verificationEnabled = verifierSettings.enabled + Task { await verifier.update(configuration: .init(enabled: verificationEnabled)) } + projects.load() projects.startGitPolling() store.start() @@ -258,6 +271,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { approvals: approvals, placement: placement, acceptance: acceptance, + verifierSettings: verifierSettings, + verifier: verifier, channels: hooks.channels, onJump: { [weak self] sessionId in self?.store.jump(to: sessionId) diff --git a/HubKit/Sources/HubCore/Acceptance.swift b/HubKit/Sources/HubCore/Acceptance.swift index 23c809a..00e8e6d 100644 --- a/HubKit/Sources/HubCore/Acceptance.swift +++ b/HubKit/Sources/HubCore/Acceptance.swift @@ -184,17 +184,27 @@ public struct AcceptanceLedger: Codable, Sendable, Equatable { public var items: [AcceptanceItem] /// 还没拆解的用户原话缓冲。 public var rawPrompts: [RawPrompt] + /// 用户点过「允许执行」的命令原文。 + /// + /// 按项目存,因为授权是有语境的:在自己的项目里跑 `swift test` 没问题, + /// 不代表在别人 clone 来的仓库里也没问题。 + /// + /// **它只是第三道闸。** 白名单和元字符检查在它之前,手工往这个数组里 + /// 塞一条危险命令绕不过前两道 —— 有测试守这一点。 + public var authorizedCommands: [String] public var updatedAt: Date public init( projectPath: String, items: [AcceptanceItem] = [], rawPrompts: [RawPrompt] = [], + authorizedCommands: [String] = [], updatedAt: Date = Date() ) { self.projectPath = projectPath self.items = items self.rawPrompts = rawPrompts + self.authorizedCommands = authorizedCommands self.updatedAt = updatedAt } diff --git a/HubKit/Sources/HubProbe/AcceptanceVerifier.swift b/HubKit/Sources/HubProbe/AcceptanceVerifier.swift new file mode 100644 index 0000000..ebb535c --- /dev/null +++ b/HubKit/Sources/HubProbe/AcceptanceVerifier.swift @@ -0,0 +1,254 @@ +import Foundation +import HubCore + +/// 一次验证的结果。 +public enum VerificationOutcome: Sendable, Equatable { + /// 真跑了。证据里带命令原文、退出码、输出尾部。 + case ran(Evidence) + /// 命令不在白名单里,或含 shell 元字符。**不执行。** + case rejected(reason: String) + /// 命令合法但用户还没授权过。**不执行。** + case needsAuthorization(command: String) + /// 总开关关着。 + case disabled + /// 验收条件是自然语言,没有可执行形式 —— 只能人工看。 + case notExecutable +} + +/// 命令的静态检查结论。 +public enum CommandCheck: Sendable, Equatable { + case allowed([String]) // 拆好的 argv + case rejected(String) // 理由 + case notACommand // 自然语言 +} + +/// **Hub 自己执行验收命令并捕获输出。** +/// +/// ## 为什么执行者必须是 Hub +/// +/// 让 Claude 跑完再把结果转述给 Hub,就又回到了「说说而已」—— 转述那一步 +/// 正是失真发生的地方。实测见过一次:我谎报"已实现自动执行验收命令", +/// 旁路复核看到 `case ran(command:exitCode:)` 这个**枚举声明**就判了通过, +/// 而没有任何代码产生它。读代码判断不了功能是否真的能用,只有跑一遍能。 +/// +/// ## 安全边界(三道闸,缺一不可) +/// +/// 验收命令来自**模型输出**,等同于不可信输入。这是全案唯一会执行命令的地方。 +/// +/// 1. **白名单**:只放行项目内的构建/测试类命令,前缀对不上一律不跑; +/// 2. **拒绝 shell 元字符**:不做转义,直接拒。配合 `Shell.run` 走 execve +/// 数组参数(不是 `sh -c`),命令注入在结构上不成立; +/// 3. **默认关闭 + 逐条授权**:总开关默认 off,开启后每条新命令首次执行前 +/// 要用户在界面上点「允许」。 +/// +/// 不做沙箱、不做超时之外的资源限制 —— 那是过度设计。三道闸把风险压在 +/// 「用户自己本来就会敲的命令」这个范围内。 +public actor AcceptanceVerifier { + + public struct Configuration: Sendable { + /// **默认关闭。** 这是唯一会执行命令的功能,不该在用户不知情时开着。 + public var enabled: Bool + /// 单条命令的上限。构建和测试都可能跑几分钟。 + public var timeout: TimeInterval + /// 留存的输出尾部长度。尾部比头部有用 —— 失败原因和统计都在最后。 + public var tailLimit: Int + + public init( + enabled: Bool = false, timeout: TimeInterval = 300, tailLimit: Int = 4_000 + ) { + self.enabled = enabled + self.timeout = timeout + self.tailLimit = tailLimit + } + } + + private var configuration: Configuration + + public init(configuration: Configuration = Configuration()) { + self.configuration = configuration + } + + public func update(configuration: Configuration) { + self.configuration = configuration + } + + public var isEnabled: Bool { configuration.enabled } + + // MARK: - 闸门①:白名单 + // + // 每一项是「命令的前若干个词」。argv 的开头必须完全匹配其中一项。 + // + // 刻意排除的: + // - `npx` / `pnpm dlx` —— 它们会下载并执行任意包,等于白名单形同虚设; + // - `npm install` / `ci` —— 有副作用(改 node_modules、可能跑 postinstall 脚本); + // - `git` 的写操作 —— 只留 diff / status / log 这些只读的。 + + static let whitelist: [[String]] = [ + ["swift", "test"], ["swift", "build"], + ["npm", "run"], ["npm", "test"], + ["pnpm", "run"], ["pnpm", "test"], + ["yarn", "run"], ["yarn", "test"], + ["make"], + ["pytest"], ["python3", "-m", "pytest"], + ["go", "test"], ["go", "build"], ["go", "vet"], + ["cargo", "test"], ["cargo", "build"], ["cargo", "clippy"], + ["bash", "scripts/"], + ["git", "diff"], ["git", "status"], ["git", "log"], + ["xcodebuild", "test"], ["xcodebuild", "build"], + ] + + // MARK: - 闸门②:shell 元字符 + // + // 一个都不许有。**不做转义,直接拒** —— 转义是个永远补不完的名单, + // 而这里完全不需要这些字符:白名单里全是 `cmd sub --flag value` 的形状。 + // + // 配合 Shell.run 走 execve 数组参数(不是 sh -c),即使漏了某个字符, + // 它也只会被当成一个普通的参数字面量传下去,不会被解释。 + // 两道一起上,不二选一。 + + static let forbidden: Set = [ + ";", "|", "&", ">", "<", "$", "`", "(", ")", "{", "}", + "\n", "\r", "\\", "\"", "'", "*", "?", "!", + ] + + /// 静态检查一条验收条件。**不执行任何东西。** + public static func classify(_ acceptance: String) -> CommandCheck { + let trimmed = acceptance.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .notACommand } + + // ⚠️ 这一步**必须在白名单之前**。放到后面的话 `swift test | tee /tmp/x` + // 会因为前缀匹配上 `swift test` 而被放行 —— 变异测试实测过。 + if let bad = trimmed.first(where: { forbidden.contains($0) }) { + return .rejected("含 shell 元字符「\(bad)」") + } + + let argv = trimmed.split(separator: " ", omittingEmptySubsequences: true).map(String.init) + guard let head = argv.first else { return .notACommand } + + // 绝对路径 / 相对路径的可执行文件一律不放行 —— 白名单是按命令名写的, + // `./build.sh` 这种绕过了它。要跑就写成 `bash scripts/build.sh`。 + guard !head.contains("/") else { + return .rejected("不接受路径形式的命令,请写成白名单内的形式") + } + + // 看起来完全不像命令名(中文描述、"打开设置页能看到开关")→ 交给人工。 + // + // 注意这里返回的是 `.notACommand` 而不是 `.rejected`:拆解器本来就允许 + // 给不出可执行形式时写自然语言,那不是错误,只是这一条没法机器验证。 + // 混成 rejected 的话,界面上会对着一堆正常的验收条件报"命令被拒绝"。 + let nameCharacters: (Character) -> Bool = { + $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == ".") + } + guard head.allSatisfy(nameCharacters) else { return .notACommand } + + for shape in whitelist where matches(argv: argv, shape: shape) { + return .allowed(argv) + } + return .rejected("「\(head)」不在白名单里") + } + + /// argv 的开头是否匹配白名单的某一项。 + /// + /// 末尾带 `/` 的项(`["bash", "scripts/"]`)表示这一段只要**以它开头**即可, + /// 用来允许 `bash scripts/任意.sh` 而不放行 `bash /etc/任意`。 + static func matches(argv: [String], shape: [String]) -> Bool { + guard argv.count >= shape.count else { return false } + for (index, expected) in shape.enumerated() { + let actual = argv[index] + if expected.hasSuffix("/") { + guard actual.hasPrefix(expected) else { return false } + } else { + guard actual == expected else { return false } + } + } + return true + } + + // MARK: - 执行 + + /// 跑一条验收命令。 + /// + /// - Parameter authorized: 用户已经点过「允许」的命令原文集合。 + public func verify( + acceptance: String?, cwd: String, authorized: Set + ) -> VerificationOutcome { + guard let acceptance else { return .notExecutable } + guard configuration.enabled else { return .disabled } + + switch Self.classify(acceptance) { + case .notACommand: + return .notExecutable + case .rejected(let reason): + return .rejected(reason: reason) + case .allowed(let argv): + let command = argv.joined(separator: " ") + // 闸门③:白名单过了也要用户点过头。白名单挡的是"这类命令危险", + // 授权挡的是"这条具体的命令我不认识"。 + guard authorized.contains(command) else { + return .needsAuthorization(command: command) + } + return execute(argv: argv, cwd: cwd, command: command) + } + } + + private func execute(argv: [String], cwd: String, command: String) -> VerificationOutcome { + guard let executable = Self.locate(argv[0]) else { + return .rejected(reason: "找不到 \(argv[0])") + } + + let result = Shell.run( + executable, + Array(argv.dropFirst()), + timeout: configuration.timeout, + environment: Self.environment(cwd: cwd), + // 必须在项目目录里跑,否则 swift test 找不到 Package.swift。 + currentDirectory: cwd + ) + + let combined = (result.stdout + result.stderr) + .trimmingCharacters(in: .whitespacesAndNewlines) + let tail = combined.count <= configuration.tailLimit + ? combined + : "…(只留尾部)\n" + String(combined.suffix(configuration.tailLimit)) + + return .ran(.ran(command: command, exitCode: result.status, tail: tail)) + } + + /// 找可执行文件。 + /// + /// **不能靠 PATH**:Hub 是 GUI app,从 launchd 起,PATH 只有 + /// `/usr/bin:/bin:/usr/sbin:/sbin` —— homebrew 装的 node / cargo 全都不在。 + /// 同 `StallJudge.locateClaude` 的理由。 + static func locate(_ name: String) -> String? { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let directories = [ + "/usr/bin", "/bin", "/usr/local/bin", "/opt/homebrew/bin", + "\(home)/.local/bin", "\(home)/.cargo/bin", "\(home)/.bun/bin", + "/usr/local/go/bin", + ] + for directory in directories { + let path = "\(directory)/\(name)" + if FileManager.default.isExecutableFile(atPath: path) { return path } + } + return nil + } + + /// 给子进程一个像样的 PATH。 + /// + /// npm 找 node、cargo 找 rustc 都靠 PATH,光定位到 npm 本身不够。 + static func environment(cwd: String) -> [String: String] { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let path = [ + "/usr/local/bin", "/opt/homebrew/bin", "\(home)/.local/bin", + "\(home)/.cargo/bin", "\(home)/.bun/bin", "/usr/local/go/bin", + "/usr/bin", "/bin", "/usr/sbin", "/sbin", + ].joined(separator: ":") + return [ + "PATH": path, + // 语言环境:launchd 给的环境里没有 LANG,一些工具会按 ASCII 处理 + // 输出,撞上中文就炸(video-digest 那边真踩过一模一样的坑)。 + "LANG": "en_US.UTF-8", + "PWD": cwd, + ] + } +} diff --git a/HubKit/Sources/HubProbe/Shell.swift b/HubKit/Sources/HubProbe/Shell.swift index f1bab7b..05d5934 100644 --- a/HubKit/Sources/HubProbe/Shell.swift +++ b/HubKit/Sources/HubProbe/Shell.swift @@ -20,15 +20,22 @@ public enum Shell { /// - Parameter environment: 在继承的环境之上追加/覆盖的变量。 /// `StallJudge` 用它注入 `HUB_JUDGE=1` 来封死 hook 回环。 @discardableResult + /// - Parameter currentDirectory: 子进程的工作目录。 + /// 验收命令(`swift test` / `npm run build`)必须在项目目录里跑, + /// 否则找不到 Package.swift / package.json。 public static func run( _ executable: String, _ arguments: [String], timeout: TimeInterval = 5, - environment: [String: String] = [:] + environment: [String: String] = [:], + currentDirectory: String? = nil ) -> Result { let process = Process() process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments + if let currentDirectory { + process.currentDirectoryURL = URL(fileURLWithPath: currentDirectory) + } if !environment.isEmpty { process.environment = ProcessInfo.processInfo.environment .merging(environment) { _, new in new } diff --git a/HubKit/Sources/HubUI/AcceptancePane.swift b/HubKit/Sources/HubUI/AcceptancePane.swift index 5283731..86192e1 100644 --- a/HubKit/Sources/HubUI/AcceptancePane.swift +++ b/HubKit/Sources/HubUI/AcceptancePane.swift @@ -22,8 +22,14 @@ struct AcceptancePane: View { let acceptance: AcceptanceStore let projects: ProjectStore let store: SessionStore + let settings: VerifierSettings + let verifier: AcceptanceVerifier @Binding var selection: String? + /// 正在跑验证的那一条。同时只允许跑一条 —— 并发跑 `swift build` + /// 会互相抢构建目录锁,结果全是假失败。 + @State private var running: String? + @State private var expanded: Set = [] @State private var draft = "" @State private var pickerOpen = false @@ -420,6 +426,7 @@ struct AcceptancePane: View { .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) .textSelection(.enabled) + verifyControls(item, acceptance) } if let note = item.note { Text(note) @@ -465,6 +472,88 @@ struct AcceptancePane: View { .padding(.leading, 15) } + // MARK: - 实际功能证明(Hub 自己跑) + + /// 验收条件下面那一行控件。 + /// + /// 三道闸的状态在这里全都要**说出来**,不能只显示一个能点的按钮: + /// 用户得知道这条为什么能跑 / 为什么不能跑。一个默默不工作的按钮 + /// 比没有按钮更糟。 + @ViewBuilder + private func verifyControls(_ item: AcceptanceItem, _ acceptance: String) -> some View { + let check = AcceptanceVerifier.classify(acceptance) + HStack(spacing: 8) { + switch check { + case .notACommand: + Text("这条只能人工确认") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + + case .rejected(let reason): + Label(reason, systemImage: "hand.raised.fill") + .font(.system(size: 10)) + .foregroundStyle(IslandTheme.danger) + + case .allowed(let argv): + let command = argv.joined(separator: " ") + if !settings.enabled { + Text("总开关关着 —— 去设置里打开「自动跑验收命令」") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } else if acceptanceStoreAuthorized(command) { + Button(running == item.id ? "跑着…" : "现在验证") { + runVerification(item, command: command) + } + .buttonStyle(.borderless) + .font(.system(size: 11, weight: .medium)) + .disabled(running != nil) + Button("撤销授权") { revoke(command) } + .buttonStyle(.borderless) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } else { + // 命令原文原样显示 —— 授权的前提是看得见到底要跑什么。 + Button("允许执行 `\(command)`") { authorize(command) } + .buttonStyle(.borderless) + .font(.system(size: 11, weight: .medium)) + } + } + Spacer() + } + } + + private func acceptanceStoreAuthorized(_ command: String) -> Bool { + guard let path = currentPath else { return false } + return acceptance.isAuthorized(command, in: path) + } + + private func authorize(_ command: String) { + guard let path = currentPath else { return } + acceptance.authorize(command, in: path) + } + + private func revoke(_ command: String) { + guard let path = currentPath else { return } + acceptance.revoke(command, in: path) + } + + private func runVerification(_ item: AcceptanceItem, command: String) { + guard let path = currentPath else { return } + running = item.id + let verifier = self.verifier + let authorized = Set(acceptance.ledger(for: path).authorizedCommands) + Task { + let outcome = await verifier.verify( + acceptance: item.acceptance, cwd: path, authorized: authorized + ) + running = nil + guard case .ran(let evidence) = outcome else { return } + acceptance.recordVerification(evidence, forID: item.id, in: path) + // 展开着才看得到结果。 + expanded.insert(item.id) + } + } + // MARK: - 手动加一条 @ViewBuilder diff --git a/HubKit/Sources/HubUI/AcceptanceStore.swift b/HubKit/Sources/HubUI/AcceptanceStore.swift index 36d81d0..88d723f 100644 --- a/HubKit/Sources/HubUI/AcceptanceStore.swift +++ b/HubKit/Sources/HubUI/AcceptanceStore.swift @@ -105,6 +105,35 @@ public final class AcceptanceStore { /// 一轮验收结束后清掉,下一轮重新攒。 public func clearTouchedFiles(sessionId: String) { touched[sessionId] = nil } + // MARK: - 验收命令的授权 + + public func isAuthorized(_ command: String, in projectPath: String) -> Bool { + ledger(for: projectPath).authorizedCommands.contains(command) + } + + public func authorize(_ command: String, in projectPath: String) { + mutate(projectPath) { ledger in + guard !ledger.authorizedCommands.contains(command) else { return } + ledger.authorizedCommands.append(command) + } + } + + public func revoke(_ command: String, in projectPath: String) { + mutate(projectPath) { $0.authorizedCommands.removeAll { $0 == command } } + } + + /// 记下一次真实执行的结果。 + /// + /// 走 `.ran` 这一档,`isProof` 为 true —— 这是 Hub **亲手**跑出来的, + /// 和 Claude 的自述不是一回事。 + public func recordVerification(_ evidence: Evidence, forID id: String, in projectPath: String) { + mutate(projectPath) { ledger in + guard let index = ledger.items.firstIndex(where: { $0.id == id }) else { return } + ledger.items[index].evidence.append(evidence) + ledger.items[index].updatedAt = Date() + } + } + // MARK: - 读 /// 取某个项目的清单。没有就返回一份空的(不落盘,等到真有内容再写)。 @@ -256,10 +285,25 @@ public final class AcceptanceStore { let pending = ledger(for: projectPath).items.filter(\.needsAttention) guard !pending.isEmpty else { return nil } - let lines = pending.map { item -> String in + // 一次最多摆这么多条。 + // + // 实测在一个真实项目上拦了 12 条,带完整验收条件之后糊了满屏 —— + // 要点一多就没人看(Claude 也一样),反而比不拦更容易被忽略。 + // 存疑的排前面:那一档是「自报做完但代码里找不到」,最该先说清楚。 + let cap = 6 + let ordered = pending.sorted { left, right in + (left.status == .disputed ? 0 : 1) < (right.status == .disputed ? 0 : 1) + } + let shown = Array(ordered.prefix(cap)) + + var lines = shown.map { item -> String in let condition = item.acceptance.map { "(验收条件:\($0))" } ?? "" return "- [\(item.id)] \(item.text)\(condition)" } + if ordered.count > cap { + // 省略了多少必须说出来。静默截断会让人以为"就这几条"。 + lines.append("(另有 \(ordered.count - cap) 条未列出,这轮先核对上面这些)") + } return """ 【Claude Hub 验收守望】以下要点来自**用户的原始需求和已批准的计划**,\ diff --git a/HubKit/Sources/HubUI/MainWindow.swift b/HubKit/Sources/HubUI/MainWindow.swift index ba4f1f2..5036570 100644 --- a/HubKit/Sources/HubUI/MainWindow.swift +++ b/HubKit/Sources/HubUI/MainWindow.swift @@ -1,6 +1,7 @@ import AppKit import HubCore import HubIPC +import HubProbe import HubProjects import SwiftUI @@ -42,6 +43,8 @@ public struct MainWindowView: View { @Bindable var approvals: ApprovalCoordinator @Bindable var placement: IslandPlacementStore @Bindable var acceptance: AcceptanceStore + @Bindable var verifierSettings: VerifierSettings + let verifier: AcceptanceVerifier let channels: HookChannelMonitor let onJump: (String) -> Void let onLaunch: (Project, LaunchMode) -> Void @@ -60,6 +63,8 @@ public struct MainWindowView: View { approvals: ApprovalCoordinator, placement: IslandPlacementStore, acceptance: AcceptanceStore, + verifierSettings: VerifierSettings, + verifier: AcceptanceVerifier, channels: HookChannelMonitor, onJump: @escaping (String) -> Void, onLaunch: @escaping (Project, LaunchMode) -> Void @@ -69,6 +74,8 @@ public struct MainWindowView: View { self.approvals = approvals self.placement = placement self.acceptance = acceptance + self.verifierSettings = verifierSettings + self.verifier = verifier self.channels = channels self.onJump = onJump self.onLaunch = onLaunch @@ -104,7 +111,8 @@ public struct MainWindowView: View { ) case .acceptance: AcceptancePane( - acceptance: acceptance, projects: projects, store: store, selection: $ledgerPath + acceptance: acceptance, projects: projects, store: store, + settings: verifierSettings, verifier: verifier, selection: $ledgerPath ) case .projects: ProjectsPane(projects: projects, store: store, git: git, onLaunch: onLaunch) @@ -115,7 +123,7 @@ public struct MainWindowView: View { case .settings: SettingsPane( approvals: approvals, projects: projects, git: git, - placement: placement, channels: channels + placement: placement, channels: channels, verifierSettings: verifierSettings ) } } @@ -697,9 +705,37 @@ struct SettingsPane: View { let git: GitAccountStore @Bindable var placement: IslandPlacementStore let channels: HookChannelMonitor + @Bindable var verifierSettings: VerifierSettings @State private var hookStatus: String = "检查中…" + /// 「Hub 自己跑验收命令」的总开关。 + /// + /// 文案上刻意把三道闸都说出来。这是全案唯一会执行命令的功能, + /// 用户有权知道边界在哪 —— 只写一句"自动验证"然后让他猜, + /// 结果只会是他不敢开,或者开了但不知道自己开了什么。 + @ViewBuilder + private var verifierCard: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle("自动跑验收命令(实际功能证明)", isOn: $verifierSettings.enabled) + .font(.system(size: 14, weight: .semibold)) + + Text(""" + 关掉时 Claude Hub 一条命令都不会执行。打开后也仍有两道限制:\ + 只放行构建/测试类命令(`swift test`、`npm run`、`bash scripts/*` 等),\ + 含 shell 元字符的一律拒绝;而且**每条命令首次执行前要你在验收页点「允许」**。 + """) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + Text("这是 Claude Hub 唯一会在你机器上执行命令的功能。") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle( + verifierSettings.enabled ? IslandTheme.waiting : Color.secondary.opacity(0.6) + ) + } + } + /// hook 通道健康度。 /// /// `hubctl doctor` 只能回答"socket 通不通"。但链路断掉的方式远不止这一种: @@ -751,6 +787,7 @@ struct SettingsPane: View { ScrollView { VStack(spacing: 12) { + Card { verifierCard } Card { hookChannels } Card { VStack(alignment: .leading, spacing: 10) { diff --git a/HubKit/Sources/HubUI/VerifierSettings.swift b/HubKit/Sources/HubUI/VerifierSettings.swift new file mode 100644 index 0000000..de180d1 --- /dev/null +++ b/HubKit/Sources/HubUI/VerifierSettings.swift @@ -0,0 +1,65 @@ +import Foundation +import Observation + +/// 「Hub 自己跑验收命令」的总开关。 +/// +/// 单独一个类型、单独一个文件,而不是塞进 `AcceptanceStore`: +/// 这是全案唯一控制**执行命令**的开关,它值得一眼就能找到, +/// 也不该和几百条验收要点混在同一份 JSON 里 —— 那份文件是会被手改的。 +/// +/// **默认关闭。** 用户装上 Hub 时不该有任何东西在背后跑命令。 +@Observable +@MainActor +public final class VerifierSettings { + + public var enabled: Bool = false { + didSet { + guard enabled != oldValue else { return } + persist() + onChange?(enabled) + } + } + + /// 开关变化后通知 verifier actor。app 层注入。 + @ObservationIgnored + public var onChange: ((Bool) -> Void)? + + /// nonisolated:要当 `init` 的默认参数值用,默认值在调用方的隔离域里求值。 + /// 同 `IslandPlacementStore.defaultURL`。 + public nonisolated static var defaultURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/claude-hub/verifier.json") + } + + /// nil = 不落盘。理由同仓库里其它几个 store: + /// 路径写死会让测试直接改用户的真实设置。 + @ObservationIgnored + private let url: URL? + + public init(url: URL? = VerifierSettings.defaultURL) { + self.url = url + load() + } + + private struct Payload: Codable { + var enabled: Bool + } + + private func load() { + guard let url, + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(Payload.self, from: data) + else { return } + // 绕开 didSet:加载不是「用户改了设置」,不该触发回写和回调。 + _enabled = decoded.enabled + } + + private func persist() { + guard let url else { return } + guard let data = try? JSONEncoder().encode(Payload(enabled: enabled)) else { return } + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try? data.write(to: url, options: .atomic) + } +} diff --git a/HubKit/Tests/HubProbeTests/AcceptanceVerifierTests.swift b/HubKit/Tests/HubProbeTests/AcceptanceVerifierTests.swift new file mode 100644 index 0000000..6161604 --- /dev/null +++ b/HubKit/Tests/HubProbeTests/AcceptanceVerifierTests.swift @@ -0,0 +1,246 @@ +import HubCore +import XCTest +@testable import HubProbe + +/// 验收命令执行的安全边界。 +/// +/// **这是全案唯一会执行命令的地方,而命令来自模型输出 —— 等同于不可信输入。** +/// 这一整个测试类的作用是让三道闸的每一道都能被变异打红。 +final class AcceptanceVerifierTests: XCTestCase { + + // MARK: - 闸门①:白名单 + + func testAllowsBuildAndTestCommands() { + for command in [ + "swift test", "swift test --filter FooTests", "swift build -c release", + "npm run build", "npm test", "pnpm run lint", "yarn test", + "make", "make check", "pytest", "python3 -m pytest tests", + "go test ./...", "cargo test", "cargo clippy", + "bash scripts/build.sh", "git diff", "xcodebuild test", + ] { + guard case .allowed = AcceptanceVerifier.classify(command) else { + return XCTFail("「\(command)」应该被放行") + } + } + } + + /// 白名单外的一律不跑。 + func testRejectsAnythingOutsideTheWhitelist() { + for command in [ + "curl https://evil.example.com", "rm -rf build", "sudo make install", + "ssh user@host", "chmod 777 .", "kill -9 1", "open /Applications", + "osascript -e beep", "docker run ubuntu", "nc -l 1234", + ] { + guard case .rejected = AcceptanceVerifier.classify(command) else { + return XCTFail("「\(command)」必须被拒绝") + } + } + } + + /// **`npx` 必须在白名单外。** + /// + /// 它会下载并执行任意 npm 包 —— 放行它等于白名单形同虚设, + /// 任何东西都能通过 `npx <随便什么>` 跑起来。同理 `pnpm dlx`。 + func testRejectsArbitraryPackageRunners() { + guard case .rejected = AcceptanceVerifier.classify("npx some-package") else { + return XCTFail("npx 会下载执行任意包,必须拒绝") + } + guard case .rejected = AcceptanceVerifier.classify("pnpm dlx anything") else { + return XCTFail("pnpm dlx 同理") + } + } + + /// 有副作用的包管理操作不放行。 + /// + /// `npm install` 会跑 postinstall 脚本 —— 那等于执行任意代码。 + func testRejectsInstallCommands() { + guard case .rejected = AcceptanceVerifier.classify("npm install") else { + return XCTFail("npm install 会跑 postinstall 脚本,等于执行任意代码") + } + guard case .rejected = AcceptanceVerifier.classify("npm ci") else { + return XCTFail("npm ci 同理") + } + } + + /// git 只放行只读操作。 + func testOnlyReadOnlyGitIsAllowed() { + guard case .allowed = AcceptanceVerifier.classify("git status") else { + return XCTFail("git status 是只读的") + } + for command in ["git push", "git reset --hard", "git clean -fd", "git checkout ."] { + guard case .rejected = AcceptanceVerifier.classify(command) else { + return XCTFail("「\(command)」会改工作区,必须拒绝") + } + } + } + + /// 路径形式的命令绕过了按名字写的白名单,必须拒。 + func testRejectsPathFormCommands() { + for command in ["./build.sh", "/bin/sh", "../evil", "~/bin/whatever"] { + guard case .rejected = AcceptanceVerifier.classify(command) else { + return XCTFail("「\(command)」是路径形式,绕过了白名单") + } + } + } + + /// `bash` 只能跑项目内 scripts/ 下的东西。 + func testBashIsScopedToProjectScripts() { + guard case .allowed = AcceptanceVerifier.classify("bash scripts/test.sh") else { + return XCTFail("项目内脚本应该放行") + } + for command in ["bash /etc/passwd", "bash ~/evil.sh", "bash -c whatever"] { + guard case .rejected = AcceptanceVerifier.classify(command) else { + return XCTFail("「\(command)」不在 scripts/ 下,必须拒绝") + } + } + } + + // MARK: - 闸门②:shell 元字符 + // + // **逐个字符各来一条**,别只测一个 —— 漏掉任何一个都是一条注入通道。 + + func testRejectsEveryShellMetacharacter() { + let attacks: [(String, String)] = [ + (";", "swift test; rm -rf /tmp/x"), + ("|", "swift test | tee /tmp/x"), + ("&", "swift test && curl evil.example.com"), + (">", "swift test > /tmp/x"), + ("<", "swift test < /etc/passwd"), + ("$", "swift test $(whoami)"), + ("`", "swift test `whoami`"), + ("(", "swift test (echo hi)"), + ("{", "swift test {a,b}"), + ("\\", "swift test \\; rm"), + ("\"", "swift test \"x\""), + ("'", "swift test 'x'"), + ("*", "swift test *"), + ("?", "swift test ?"), + ("!", "swift test !!"), + ("换行", "swift test\nrm -rf /tmp/x"), + ] + for (name, command) in attacks { + guard case .rejected(let reason) = AcceptanceVerifier.classify(command) else { + return XCTFail("含「\(name)」的命令必须被拒绝:\(command)") + } + XCTAssertTrue( + reason.contains("元字符"), + "拒绝理由应指明是元字符,实际是「\(reason)」" + ) + } + } + + /// 元字符检查必须**先于**白名单 —— 否则 `swift test; rm -rf /` 会因为 + /// 前缀匹配上 `swift test` 而被放行。 + func testMetacharacterCheckRunsBeforeTheWhitelist() { + guard case .rejected(let reason) = AcceptanceVerifier.classify("swift test; rm -rf /") else { + return XCTFail("白名单前缀匹配不能让注入溜过去") + } + XCTAssertTrue(reason.contains("元字符")) + } + + // MARK: - 自然语言不是错误 + + /// 拆解器本来就允许给不出命令时写自然语言。 + /// + /// 那不是错误,只是这一条没法机器验证。混成 `rejected` 的话, + /// 界面上会对着一堆正常的验收条件报"命令被拒绝"。 + func testPlainLanguageIsNotACommand() { + for text in ["打开设置页能看到开关", "手动点一下看看", "用户能正常翻页", ""] { + guard case .notACommand = AcceptanceVerifier.classify(text) else { + return XCTFail("「\(text)」是自然语言,不该报拒绝") + } + } + } + + // MARK: - 闸门③:总开关与逐条授权 + + /// **总开关默认必须是关的。** + /// + /// 这是唯一会执行命令的功能,不该在用户不知情时开着。 + func testDisabledByDefault() async { + let verifier = AcceptanceVerifier() + let enabled = await verifier.isEnabled + XCTAssertFalse(enabled, "默认必须关闭") + } + + func testDisabledVerifierRunsNothing() async { + let verifier = AcceptanceVerifier() + let outcome = await verifier.verify( + acceptance: "swift test", cwd: "/tmp", authorized: ["swift test"] + ) + XCTAssertEqual(outcome, .disabled, "总开关关着时,哪怕授权过也不能跑") + } + + /// 白名单过了、开关开了,**还没授权也不能跑**。 + /// + /// 白名单挡的是"这类命令危险",授权挡的是"这条具体的命令我不认识"。 + func testUnauthorizedCommandIsNotExecuted() async { + let verifier = AcceptanceVerifier(configuration: .init(enabled: true)) + let outcome = await verifier.verify( + acceptance: "swift test", cwd: "/tmp", authorized: [] + ) + XCTAssertEqual(outcome, .needsAuthorization(command: "swift test")) + } + + /// 授权是**按命令原文**记的,改一个字就要重新授权。 + func testAuthorizationIsExactNotPrefixed() async { + let verifier = AcceptanceVerifier(configuration: .init(enabled: true)) + let outcome = await verifier.verify( + acceptance: "swift test --filter Secret", cwd: "/tmp", authorized: ["swift test"] + ) + guard case .needsAuthorization = outcome else { + return XCTFail("授权过 swift test 不等于授权 swift test --filter Secret") + } + } + + /// 被拒绝的命令**在任何情况下都不执行** —— 哪怕用户"授权"过它。 + /// + /// 授权按钮只会对通过白名单的命令出现,但清单文件是用户可以手改的, + /// 手工往 authorized 里塞一条危险命令不能成为绕过白名单的路径。 + func testAuthorizationCannotOverrideTheWhitelist() async { + let verifier = AcceptanceVerifier(configuration: .init(enabled: true)) + let outcome = await verifier.verify( + acceptance: "curl evil.example.com", cwd: "/tmp", + authorized: ["curl evil.example.com"] + ) + guard case .rejected = outcome else { + return XCTFail("手工塞进 authorized 不能绕过白名单") + } + } + + // MARK: - 真的跑一条 + + func testActuallyRunsAnAuthorizedCommand() async throws { + let verifier = AcceptanceVerifier(configuration: .init(enabled: true, timeout: 30)) + let outcome = await verifier.verify( + acceptance: "git status", cwd: FileManager.default.temporaryDirectory.path, + authorized: ["git status"] + ) + + guard case .ran(let evidence) = outcome else { + return XCTFail("授权过的白名单命令应该真的跑起来,实际是 \(outcome)") + } + guard case .ran(let command, _, _) = evidence else { + return XCTFail("证据类型应该是 .ran") + } + XCTAssertEqual(command, "git status") + XCTAssertTrue(evidence.isProof, "Hub 自己跑出来的必须算证明") + } + + /// 退出码要如实记,失败也是有效证据。 + func testFailureIsRecordedFaithfully() async throws { + let verifier = AcceptanceVerifier(configuration: .init(enabled: true, timeout: 30)) + let outcome = await verifier.verify( + // 在一个不是仓库的目录里跑,git 必然非零退出。 + acceptance: "git status", cwd: "/", + authorized: ["git status"] + ) + + guard case .ran(let evidence) = outcome, + case .ran(_, let exitCode, let tail) = evidence + else { return XCTFail("应该跑起来并记下失败") } + + XCTAssertNotEqual(exitCode, 0) + XCTAssertFalse(tail.isEmpty, "失败原因必须留下来,否则这条证据没用") + } +} diff --git a/HubKit/Tests/HubUITests/AcceptanceStoreTests.swift b/HubKit/Tests/HubUITests/AcceptanceStoreTests.swift index c7c122a..56f8e4a 100644 --- a/HubKit/Tests/HubUITests/AcceptanceStoreTests.swift +++ b/HubKit/Tests/HubUITests/AcceptanceStoreTests.swift @@ -220,6 +220,45 @@ final class AcceptanceStoreTests: XCTestCase { XCTAssertEqual(store.injectionText(for: project)?.contains("不是你自己列的 todo"), true) } + /// 一次最多摆 6 条,而且**省略了多少必须说出来**。 + /// + /// 实测在一个真实项目上拦了 12 条,带完整验收条件之后糊了满屏 —— + /// 要点一多就没人看(Claude 也一样),反而比不拦更容易被忽略。 + /// 静默截断更糟:会让人以为"就这几条"。 + func testInjectionTextIsCappedAndSaysHowManyWereOmitted() { + let store = AcceptanceStore(directory: nil) + for index in 0..<12 { + store.add(AcceptanceItem(text: "要点 \(index)", origin: .userPrompt), to: project) + } + + let text = store.injectionText(for: project) ?? "" + let listed = text.split(separator: "\n").filter { $0.hasPrefix("- [") }.count + + XCTAssertEqual(listed, 6) + XCTAssertTrue(text.contains("另有 6 条未列出"), "省略了多少必须说出来") + } + + /// 存疑的必须排在注入正文最前面。 + /// + /// 那一档是「自报做完但代码里找不到」—— 最该先说清楚的一类, + /// 排在后面会被 6 条的上限挤掉。 + func testDisputedItemsComeFirstInTheInjection() { + let store = AcceptanceStore(directory: nil) + for index in 0..<8 { + store.add(AcceptanceItem(text: "普通要点 \(index)", origin: .userPrompt), to: project) + } + let suspicious = AcceptanceItem(text: "这条自报做了但找不到", origin: .userPrompt) + store.add(suspicious, to: project) + store.setStatus(.disputed, forID: suspicious.id, in: project) + + let text = store.injectionText(for: project) ?? "" + + XCTAssertTrue( + text.contains("这条自报做了但找不到"), + "存疑项被 6 条上限挤掉了 —— 它恰恰是最该先说的" + ) + } + /// 已经有定论的项不该出现在注入正文里。 func testInjectionTextOmitsSettledItems() { let store = AcceptanceStore(directory: nil)