Files
vibe-foreman/HubKit/Sources/HubProbe/Shell.swift
T
wuguanganandClaude Opus 5 a5ba3f7573 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>
2026-08-07 19:37:45 +08:00

105 lines
4.0 KiB
Swift
Raw 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
/// 极薄的外部命令封装。只用于确实没有 API 替代的场景:`tmux` 和 `osascript`。
///
/// 进程信息一律走 `ProcessTree`(sysctl),不要用这个去调 `ps`。
public enum Shell {
public struct Result: Sendable {
public let status: Int32
public let stdout: String
public let stderr: String
public var succeeded: Bool { status == 0 }
}
/// 同步执行并捕获输出。
///
/// - Parameter timeout: 超时秒数。tmux 在 server 无响应时会挂住,
/// AppleScript 在目标 app 卡死时更会挂很久 —— 探测循环里必须有上界,
/// 否则整个 UI 的刷新会被一个卡住的 osascript 拖死。
/// - 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] = [:],
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 }
}
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
// /dev/null 而不是不设:子进程读 stdin 时会立刻拿到 EOF。
// `claude -p` 在拿不到 stdin 时会干等 3 秒才继续,这里直接避掉。
process.standardInput = FileHandle.nullDevice
do {
try process.run()
} catch {
return Result(status: -1, stdout: "", stderr: "\(error)")
}
// 必须在 waitUntilExit 之前把管道读干:管道缓冲区约 64KB,
// 写满后子进程会阻塞在 write 上,而我们阻塞在 wait 上 —— 经典死锁。
// 后台线程读取可以同时避免这个死锁和超时逻辑的竞争。
var outData = Data()
var errData = Data()
let group = DispatchGroup()
let queue = DispatchQueue(label: "hub.shell.io", attributes: .concurrent)
group.enter()
queue.async {
outData = outPipe.fileHandleForReading.readDataToEndOfFile()
group.leave()
}
group.enter()
queue.async {
errData = errPipe.fileHandleForReading.readDataToEndOfFile()
group.leave()
}
let deadline = Date().addingTimeInterval(timeout)
while process.isRunning, Date() < deadline {
usleep(5_000)
}
if process.isRunning {
process.terminate()
// 给 SIGTERM 一点时间,不行就硬杀,避免留下僵尸拖住管道读取线程。
let hardDeadline = Date().addingTimeInterval(0.5)
while process.isRunning, Date() < hardDeadline { usleep(5_000) }
if process.isRunning { kill(process.processIdentifier, SIGKILL) }
}
process.waitUntilExit()
group.wait()
return Result(
status: process.terminationStatus,
stdout: String(decoding: outData, as: UTF8.self),
stderr: String(decoding: errData, as: UTF8.self)
)
}
/// 执行 AppleScript。
public static func osascript(_ source: String, timeout: TimeInterval = 8) -> Result {
run("/usr/bin/osascript", ["-e", source], timeout: timeout)
}
}