阶段 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>
105 lines
4.0 KiB
Swift
105 lines
4.0 KiB
Swift
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)
|
||
}
|
||
}
|