feat: Stop 拦截注入 —— 收工时拿清单逼它逐条报证据

Stop 从「非阻塞、输出被忽略」改成阻塞式,有待验收项时输出
{"decision":"block","reason":<清单>} 让 Claude 不停下来,先逐条核对。
下一轮的 lastAssistantMessage 里的自查 JSON 被收回来,把 open 推到 claimed。

Stop 的超时阶梯和审批那条**完全分开**,方向也相反:

  3s HookCoordinator / 5s hubctl / 10s settings.json,超时一律 = 不拦。

审批超时 = 拒绝(安全刹车);这里超时 = 放行。写反的话 Hub 一出问题
所有会话就再也收不了工,比"少提醒一次验收"严重得多。实测 hook 本身
6-11ms,Hub 在不在都一样。

查出来三个真问题,都不是靠读代码发现的:

1. 服务端从不给 stop 回写应答。我改了 hubctl 让它等,却没动
   HubSocketServer 里写死的 guard kind == .preToolUse —— 拦截一次都没生效
   过,而且不报错、不超时、10ms 就返回,表现得和"清单里没有待办"
   一模一样。端到端测了三轮才找到。修法不是补一个 if,是加
   Kind.expectsDecision 让两边共用同一个判断。

   这和上次「grep activeScreen() 漏了一个调用点」是同一类错误:
   改了协议的一端,假设另一端会跟上。

2. SIGPIPE 会杀掉 Hub。服务端开始给 stop 回写之后,遇到不等应答就关连接
   的客户端(旧版 hubctl、被 Ctrl-C 的 hook),那次 write 直接终止进程 ——
   不是报错,是进程没了。测试崩在 signal 13 上才暴露。
   先加了 per-socket 的 SO_NOSIGPIPE(看起来更讲究),结果没生效,
   照样崩;改成进程级 signal(SIGPIPE, SIG_IGN) 才好。
   更讲究的写法如果不生效,就只是更讲究的 bug。

3. stop 事件不能去重。缓存决策只有在决策是事件的纯函数时才成立,
   而 stop 的结论取决于会变的上膛状态:同样内容的两条 stop,
   一条在用户说话前、一条在之后,结论截然相反。实测被这个坑到过。

4. {"done":1} 被判成做完了。JSONSerialization 把数字 1 桥接成 NSNumber,
   as? Bool 对它成立 —— 模型只要把 done 写成数字就能绕过"含糊按没做算"。
   改用 CFBooleanGetTypeID 严格判定。

端到端实测(真实 hubctl 二进制):未上膛不拦、上膛后拦下 13 条要点、
连着两次收工都放行、Hub 存活、自查回答(带散文和围栏)正确回收成
claimed 且 hasProof 仍为 false。

346 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
wuguanganandClaude Opus 5 committed 2026-08-07 18:10:35 +08:00
1 parent f0f6cd7454
commit fe3febbfd2
13 files changed
+518 -26

No files matched your search

+36
View File
@@ -42,6 +42,24 @@ public struct HookEvent: Codable, Sendable {
/// 压缩正是"遗忘"最集中发生的时刻 —— Claude 记不住的东西,
/// Hub 的清单记得住。
case preCompact
/// 这类事件的发起方在等一个决策,服务端必须回写。
///
/// **hubctl 和 HubSocketServer 必须都用这一个属性判断,别各写各的 `if`。**
///
/// 这条来自一次真实事故:把 Stop 改成阻塞式时只动了 hubctl(让它等应答),
/// 服务端那边的 `guard kind == .preToolUse` 原封不动 —— 于是 hubctl 等到的
/// 永远是连接关闭,拦截**一次都没生效过**,而且不报错、不超时、
/// 表现得和"清单里没有待办"一模一样。查了三轮才找到。
///
/// 两边共用一个判断之后,这种分歧在结构上就不成立了。
public var expectsDecision: Bool {
switch self {
case .preToolUse, .stop: return true
case .notification, .sessionEnd, .userPromptSubmit,
.sessionStart, .postToolUse, .subagentStop, .preCompact: return false
}
}
}
public let kind: Kind
@@ -188,6 +206,24 @@ public struct HookDecision: Codable, Sendable {
guard let data = try? JSONSerialization.data(withJSONObject: output) else { return nil }
return String(data: data, encoding: .utf8)
}
/// Stop hook 应答的 stdout 内容。
///
/// `{"decision":"block","reason":...}` 会让 Claude **不停下来**,
/// 并把 reason 当成新的输入继续跑一轮 —— 验收清单就是靠这个塞回去的。
///
/// nil = 什么都不输出 = 正常收工。**这是绝大多数情况**,也是所有异常
/// 情况的落点:Hub 没运行、桥接超时、清单为空、没上膛,全都走这里。
///
/// 注意和 `hookOutputJSON()` 的格式**完全不同**(那个是
/// hookSpecificOutput + permissionDecision),别想着合并成一个。
public func stopOutputJSON() -> String? {
guard verdict == .deny, let reason, !reason.isEmpty else { return nil }
guard let data = try? JSONSerialization.data(
withJSONObject: ["decision": "block", "reason": reason]
) else { return nil }
return String(data: data, encoding: .utf8)
}
}
/// socket 路径。
+14 -1
View File
@@ -30,6 +30,11 @@ public final class HubSocketServer: @unchecked Sendable {
}
public func start() throws {
// 客户端可能在我们回写决策之前就关掉连接(不等应答的 hook、
// 被 Ctrl-C 掉的 hubctl、旧版二进制)。不忽略 SIGPIPE 的话,
// 那一次 write 会**直接杀掉 Claude Hub** —— 不是报错,是进程没了。
UnixSocket.ignoreSIGPIPEProcessWide()
let fd = try UnixSocket.listen(at: path)
listenFd = fd
@@ -50,6 +55,9 @@ public final class HubSocketServer: @unchecked Sendable {
private func acceptOne() {
let clientFd = accept(listenFd, nil, nil)
guard clientFd >= 0 else { return }
// 客户端可能在我们回写之前就关掉连接(不等应答的 hook、被 Ctrl-C 掉的
// hubctl)。不关掉 SIGPIPE 的话那一次 write 会**直接杀掉 Claude Hub**。
UnixSocket.suppressSIGPIPE(fd: clientFd)
workQueue.async { [handler] in
defer { close(clientFd) }
@@ -63,7 +71,8 @@ public final class HubSocketServer: @unchecked Sendable {
let decision = handler(event)
guard event.kind == .preToolUse else { return }
// 用 Kind.expectsDecision,别在这里写死类型 —— 见那个属性上的事故说明。
guard event.kind.expectsDecision else { return }
let payload = decision ?? .allow
if let encoded = try? JSONEncoder().encode(payload),
let text = String(data: encoded, encoding: .utf8) {
@@ -102,6 +111,10 @@ public enum HubSocketClient {
waitForDecision: Bool,
timeout: TimeInterval
) -> HookSendResult {
// Hub 可能正在退出,服务端先关了。被 SIGPIPE 杀掉的 hook 等同于
// 没有输出 —— 对审批链路来说那就是放行,正是 fail-open 的反面。
UnixSocket.ignoreSIGPIPEProcessWide()
guard let data = try? JSONEncoder().encode(event),
let line = String(data: data, encoding: .utf8)
else { return .unreachable }
+27
View File
@@ -44,4 +44,31 @@ public enum HookTimeouts {
/// 必须大于 `clientRead`,否则 hubctl 还没来得及输出拒绝就被杀了,
/// 而被杀的 hook 等同于没有输出 —— 也就是放行。
public static let hookProcess: Int = 90
// MARK: - Stop 的阶梯(独立的一组,和上面那条不共用)
//
// Stop 变成阻塞式之后,**每一次收工都要等 Hub 应答**。挂在上面那条
// 75 秒的阶梯上是不能接受的:Hub 卡住时用户每说一句话就要干等 75 秒。
//
// 更重要的是**方向相反**。审批链路上超时 = 拒绝(安全刹车);
// Stop 链路上超时 = **不拦**。写反的话,Hub 一出问题所有会话就再也
// 收不了工 —— 那是比"少提醒一次验收"严重得多的故障。
//
// ```
// 3s HookCoordinator 等 MainActor 算出该不该拦 → 到点就不拦
// 5s hubctl 读 socket 等服务端应答 → 到点什么都不输出 = 正常收工
// 10s settings.json Claude 杀掉 hubctl 的上限
// ```
//
// 三个数都小得多,因为这里不需要等人 —— 该不该拦是查内存里的清单,
// 毫秒级就有答案。留 3 秒纯粹是给 MainActor 排队的余量。
/// 服务端等 MainActor 给出"拦不拦"。到点 = 不拦。
public static let stopBridge: TimeInterval = 3
/// hubctl 等服务端应答。到点 = 什么都不输出 = 正常收工。
public static let stopRead: TimeInterval = 5
/// 写进 settings.json 的 Stop hook 超时。
public static let stopHookProcess: Int = 10
}
+37
View File
@@ -88,6 +88,9 @@ enum UnixSocket {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { throw SocketError.createFailed(errno) }
// 服务端也可能先关(Hub 正在退出)。同样不能让写失败变成杀掉 hubctl ——
// 被信号杀掉的 hook 等同于没有输出,而那正是 fail-open 的反面。
suppressSIGPIPE(fd: fd)
let connected = withAddress(&addr) { Darwin.connect(fd, $0, $1) }
guard connected == 0 else {
@@ -131,6 +134,40 @@ enum UnixSocket {
/// 写一行。
@discardableResult
/// 关掉这个 fd 的 SIGPIPE。
///
/// **不加这一句,往一个对端已关闭的 socket 写数据会直接杀掉整个进程。**
/// 默认行为是发 SIGPIPE,而 SIGPIPE 的默认处置是终止 —— 不是返回错误码,
/// 是进程没了。
///
/// 这不是理论风险,是实测撞到的:Stop 改成需要应答之后,服务端会给 stop
/// 回写决策;而不等应答的客户端(旧版 hubctl、被 Ctrl-C 掉的 hook)
/// 早就把连接关了。于是每一次这样的收工都会**杀掉 Claude Hub**。
/// 测试套件是直接崩在 signal 13 上才暴露的。
///
/// 用 per-socket 的 `SO_NOSIGPIPE` 而不是全局 `signal(SIGPIPE, SIG_IGN)`:
/// 全局改会影响进程里所有别的代码(包括 Swift 运行时和第三方库),
/// 而这里只需要让这几个 socket 上的写失败退化成返回 -1 + EPIPE。
static func suppressSIGPIPE(fd: Int32) {
var on: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
}
/// 进程级忽略 SIGPIPE。**必须调,per-socket 的那个不够。**
///
/// 一开始只加了 `SO_NOSIGPIPE`(per-socket,影响面更小,看起来更讲究),
/// 结果测试照样崩在 signal 13 上 —— 那个 setsockopt 的返回值没人检查,
/// 而它显然没起作用。这是"选了看起来更干净的方案,却没验证它真的有效"
/// 的典型:更讲究的写法如果不生效,就只是更讲究的 bug。
///
/// 忽略之后写失败退化成返回 -1 + EPIPE,由 `writeLine` 正常返回 false。
/// 对网络服务来说这是标准做法,不是权宜之计。
///
/// 幂等,多次调用无害。
static func ignoreSIGPIPEProcessWide() {
signal(SIGPIPE, SIG_IGN)
}
static func writeLine(fd: Int32, _ line: String) -> Bool {
var data = Array((line + "\n").utf8)
var offset = 0
+114 -3
View File
@@ -108,8 +108,7 @@ public final class HookCoordinator {
private nonisolated func dispatch(_ event: HookEvent) -> HookDecision {
switch event.kind {
case .stop:
Task { @MainActor in self.handleStop(event) }
return .allow
return handleStopSynchronously(event)
case .notification:
Task { @MainActor in self.handleNotification(event) }
@@ -142,10 +141,47 @@ public final class HookCoordinator {
}
}
/// 收工。**阻塞式** —— hubctl 在等"要不要把验收清单塞回去"。
///
/// ## 兜底方向和审批链路相反,这是本方法最要紧的一条
///
/// `handlePreToolUseSynchronously` 超时兜底是 **deny**(安全刹车)。
/// 这里超时兜底必须是 **allow = 不拦**。写反的话 Hub 一出问题,
/// 所有会话就再也收不了工 —— 比"少提醒一次验收"严重得多。
///
/// 3 秒足够:该不该拦是查内存里的清单,毫秒级就有答案,
/// 这 3 秒纯粹是给 MainActor 排队留的余量。
private nonisolated func handleStopSynchronously(_ event: HookEvent) -> HookDecision {
let semaphore = DispatchSemaphore(value: 0)
// 同 handlePreToolUseSynchronously:只有下面的 Task 写、wait 之后才读。
nonisolated(unsafe) var result = HookDecision.allow
Task { @MainActor in
result = self.handleStop(event)
semaphore.signal()
}
if semaphore.wait(timeout: .now() + HookTimeouts.stopBridge) == .timedOut {
HubLog.ipc.error("收工桥接超时 —— 放行(绝不能因为 Hub 慢就让会话停不下来)")
return .allow
}
return result
}
@MainActor
private func handleStop(_ event: HookEvent) {
private func handleStop(_ event: HookEvent) -> HookDecision {
store.refresh()
let path = AcceptanceStore.projectPath(forCWD: event.cwd, projects: projects)
// 先收自查回答,再决定拦不拦。
//
// 顺序不能反:上一轮拦下来之后 Claude 的回答就在这条事件的
// lastAssistantMessage 里,先收下来,这一轮的清单才是最新的。
collectClaims(from: event, projectPath: path)
let decision = interceptDecision(for: event, projectPath: path)
let project = projects.projectName(forPath: event.cwd) ?? event.fallbackProjectName
let session = store.sessions.first { $0.sessionId == event.sessionId }
let title = session?.name ?? project
@@ -154,6 +190,13 @@ public final class HookCoordinator {
// ("搞定了,来看看" 之类,信息量为零,用户还得切过去才知道干了什么)。
let body = Self.summarize(event.lastAssistantMessage) ?? "完成了一轮回复"
// 被拦下来的这一轮不是"收工",别发完成通知 —— Claude 马上还要再跑一轮,
// 现在弹「✅ 完成了」是在骗用户。
guard decision.verdict != .deny else {
HubLog.app.notice("验收守望:拦下 \(title, privacy: .public) 的收工,要求逐条核对")
return decision
}
notifications.post(title: "✅ \(title)", body: body, sessionId: event.sessionId)
if allowIntrusion(for: event.sessionId) {
@@ -164,6 +207,74 @@ public final class HookCoordinator {
)
)
}
return decision
}
/// 该不该拦这一次收工。
@MainActor
private func interceptDecision(for event: HookEvent, projectPath: String) -> HookDecision {
// Claude 已经因为 Stop hook 在续跑了 —— 绝不能再拦。
//
// 这是**第二道**。真正的保证是下面 disarmAndShouldIntercept 里的上膛机制,
// 因为这个字段在本机没验证过,不同 CLI 版本给不给都不确定。
guard event.stopHookActive != true else { return .allow }
guard acceptance.disarmAndShouldIntercept(
sessionId: event.sessionId, projectPath: projectPath
) else { return .allow }
guard let text = acceptance.injectionText(for: projectPath) else { return .allow }
return HookDecision(verdict: .deny, reason: text)
}
/// 收下 Claude 上一轮的逐条自查回答。
///
/// 解析不出来就什么都不做 —— 它很可能是直接去补做遗漏项了(那其实是好事),
/// 或者干脆没按格式答。这两种情况都退回旁路复核,不该报错也不该清状态。
@MainActor
private func collectClaims(from event: HookEvent, projectPath: String) {
guard let message = event.lastAssistantMessage,
let claims = Self.parseClaims(message), !claims.isEmpty
else { return }
acceptance.applyClaims(claims, in: projectPath)
HubLog.app.notice("验收守望:收下 \(claims.count, privacy: .public) 条自查回答")
}
/// 从 Claude 的回复里挖出自查 JSON。
///
/// 走 `ModelOutput.extractJSONObject` 而不是直接解析:模型在 JSON 前后
/// 各写一段话是常态("我核对了一遍:{…} 需要我继续吗?"),
/// 而这里它是在一段长对话的末尾被要求输出 JSON 的,更容易加客套话。
nonisolated static func parseClaims(_ message: String) -> [AcceptanceClaim]? {
guard let object = ModelOutput.extractJSONObject(message),
let raw = object["items"] as? [[String: Any]]
else { return nil }
return raw.compactMap { item in
guard let id = item["id"] as? String, !id.isEmpty else { return nil }
return AcceptanceClaim(
id: id,
// done 缺失或不是**真正的布尔**时按没做算。方向是刻意的:
// 把含糊不清当成"做完了",等于给虚报开了个免检通道。
done: Self.strictlyTrue(item["done"]),
evidence: (item["evidence"] as? String) ?? ""
)
}
}
/// 只有 JSON 里字面写的 `true` 才算数。
///
/// **不能用 `as? Bool`。** JSONSerialization 把 `true` 和 `1` 都解成 NSNumber,
/// 而 NSNumber 到 Bool 的桥接对 `1` 是成立的 —— 于是 `{"done":1}` 会被判成
/// 做完了。测试抓到过:模型只要把 done 写成数字就能绕过"含糊按没做算"。
///
/// CFBoolean 和 CFNumber 是不同的 CF 类型,按类型 ID 判才分得开。
nonisolated static func strictlyTrue(_ value: Any?) -> Bool {
guard let number = value as? NSNumber,
CFGetTypeID(number) == CFBooleanGetTypeID()
else { return false }
return number.boolValue
}
/// 用户提交了一句话。
+14 -1
View File
@@ -58,7 +58,20 @@ final class HookDedup: @unchecked Sendable {
// 空输入退化成空串是安全的:5 秒内两条空输入吞掉一条没有任何损失。
discriminator = event.promptText ?? ""
case .stop:
discriminator = event.lastAssistantMessage ?? event.stopReason ?? ""
// **刻意不去重。**
//
// 缓存决策只有在决策是事件的**纯函数**时才成立。preToolUse 满足
// (同一次工具调用永远同一个结论),stop 不满足 —— 它取决于会变的
// 上膛状态:同样内容的两条 stop,一条在用户说话前、一条在之后,
// 结论截然相反。
//
// 实测踩到过:先发一条未上膛的 stop(不拦),再上膛、再发一条内容
// 一模一样的 stop —— 第二条被当成副本,复用了"不拦",拦截静默失效。
//
// 不去重的代价只是重复 stop 会多发一条通知,而重复 stop 本来就
// 几乎不会发生(相同命令 Claude Code 自己会去重)。上膛机制自身
// 也是安全的:第一条消费掉膛,第二条自然看到未上膛。
discriminator = nil
case .notification:
discriminator = event.message ?? event.notificationType ?? ""
case .sessionStart, .sessionEnd, .subagentStop, .preCompact:
+17 -2
View File
@@ -146,10 +146,25 @@ private func runHook(_ name: String) -> Int32 {
let result = HubSocketClient.send(
event,
waitForDecision: kind == .preToolUse,
timeout: approvalTimeout
// 和服务端共用同一个判断,见 Kind.expectsDecision 上的事故说明。
waitForDecision: kind.expectsDecision,
// Stop 走独立的短阶梯:它挡在**每一次收工**前面,
// 用 75 秒会让 Hub 一卡住用户就永远收不了工。
timeout: kind == .stop ? HookTimeouts.stopRead : approvalTimeout
)
// Stop 的应答和 PreToolUse **方向相反**,单独处理。
//
// PreToolUse 的 .noResponse 要保守拒绝(服务端认为该拦,只是没答上来);
// Stop 的一切异常都必须什么都不输出 = 正常收工。写反的话 Hub 一出问题
// 所有会话都再也停不下来 —— 比"少提醒一次验收"严重得多。
if kind == .stop {
if case .decided(let decision) = result, let text = decision.stopOutputJSON() {
print(text)
}
return 0
}
guard kind == .preToolUse else { return 0 }
// 排障开关:HUB_DEBUG=1 时把收到的结果打到 stderr。
@@ -80,6 +80,14 @@ final class SocketRoundTripTests: XCTestCase {
}
}
/// 顺带也是 **SIGPIPE 的回归护栏**,别删。
///
/// 它模拟的是"客户端不等应答就关连接"——旧版 hubctl、被 Ctrl-C 掉的 hook
/// 都是这个形态。Stop 改成需要应答之后,服务端会往这个已关闭的连接写决策,
/// 而没有忽略 SIGPIPE 的话那一次 write 会**直接杀掉整个进程**。
///
/// 当时的表现就是这条测试崩在 signal 13 上(不是断言失败,是进程没了)。
/// 生产环境里对应的是"新 Hub + 旧 hubctl → 每次收工 Claude Hub 都被杀掉"。
func testFireAndForgetEventReachesServer() throws {
let received = XCTestExpectation(description: "服务端收到事件")
nonisolated(unsafe) var seen: HookEvent?
@@ -0,0 +1,108 @@
import XCTest
@testable import HubIPC
/// Stop hook 的应答格式和超时阶梯。
///
/// Stop 从「非阻塞、输出被忽略」改成了「阻塞、输出决定 Claude 停不停」,
/// 这是全案侵入性最大的改动 —— 它挡在**每一次收工**前面。
final class StopHookOutputTests: XCTestCase {
// MARK: - 输出格式
/// 绝大多数情况都走这条:什么都不输出 = 正常收工。
func testAllowPrintsNothing() {
XCTAssertNil(HookDecision.allow.stopOutputJSON())
}
func testDenyBlocksWithTheReason() throws {
let decision = HookDecision(verdict: .deny, reason: "还有 3 项没核对")
let json = try XCTUnwrap(decision.stopOutputJSON())
let object = try XCTUnwrap(
JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]
)
XCTAssertEqual(object["decision"] as? String, "block")
XCTAssertEqual(object["reason"] as? String, "还有 3 项没核对")
}
/// 没有理由的拦截等于让 Claude 空转一轮 —— 它收不到任何指示,
/// 只能再说一遍"做完了",然后又被拦。宁可不拦。
func testDenyWithoutAReasonDoesNotBlock() {
XCTAssertNil(HookDecision(verdict: .deny, reason: nil).stopOutputJSON())
XCTAssertNil(HookDecision(verdict: .deny, reason: "").stopOutputJSON())
}
/// `ask` 是 PreToolUse 的语义,Stop 这条链路上没有对应概念 —— 不该输出。
func testAskPrintsNothingOnTheStopChannel() {
XCTAssertNil(HookDecision(verdict: .ask, reason: "随便").stopOutputJSON())
}
/// Stop 和 PreToolUse 的输出格式**完全不同**,别想着合并成一个方法。
///
/// PreToolUse 是 `hookSpecificOutput.permissionDecision`,
/// Stop 是顶层的 `decision` / `reason`。用错格式的后果是静默失效:
/// Claude 认不出来就当没有输出,也就是照常收工。
func testTheTwoChannelsUseDifferentShapes() throws {
let decision = HookDecision(verdict: .deny, reason: "拦一下")
let stop = try XCTUnwrap(decision.stopOutputJSON())
let pre = try XCTUnwrap(decision.hookOutputJSON())
XCTAssertTrue(stop.contains("\"decision\""))
XCTAssertFalse(stop.contains("hookSpecificOutput"))
XCTAssertTrue(pre.contains("hookSpecificOutput"))
XCTAssertFalse(pre.contains("\"decision\":\"block\""))
}
// MARK: - 客户端和服务端必须对「谁在等应答」有共识
/// **阻塞类事件的集合只能有一个定义。**
///
/// 这条来自一次真实事故:把 Stop 改成阻塞式时只动了 hubctl(让它等应答),
/// 服务端 `HubSocketServer` 里那句 `guard kind == .preToolUse` 原封不动 ——
/// 于是 hubctl 等到的永远是连接关闭。
///
/// 最难受的是它的**症状**:不报错、不超时、hook 10ms 就返回,
/// 表现得和"清单里没有待办所以不用拦"一模一样。端到端测了三轮才找到。
///
/// 现在两边都读 `Kind.expectsDecision`,分歧在结构上不成立。
/// 这条测试钉住的是这个集合本身。
func testOnlyBlockingKindsExpectADecision() {
XCTAssertTrue(HookEvent.Kind.preToolUse.expectsDecision)
XCTAssertTrue(HookEvent.Kind.stop.expectsDecision, "Stop 是阻塞的,服务端必须回写")
for kind in [HookEvent.Kind.notification, .sessionEnd, .userPromptSubmit,
.sessionStart, .postToolUse, .subagentStop, .preCompact] {
XCTAssertFalse(
kind.expectsDecision,
"\(kind.rawValue) 不该等应答 —— 等了会让 hubctl 白白挂在 socket 上"
)
}
}
// MARK: - 超时阶梯
/// **内层必须先到期。**
///
/// 这条守的是一次真实事故的镜像:审批那边曾经把服务端和客户端的超时设成
/// 一样大,结果 hubctl 先判"没收到应答"按 fail-open 放行了一条
/// `git push --force`,而服务端其实已经决定拒绝。
///
/// Stop 这条链路方向相反(超时 = 不拦),但阶梯必须同样严格 ——
/// 顺序错了会出现"服务端说要拦、客户端已经超时放过"的窗口,
/// 表现为拦截时灵时不灵,极难排查。
func testTheStopLadderIsStrictlyIncreasing() {
XCTAssertLessThan(HookTimeouts.stopBridge, HookTimeouts.stopRead)
XCTAssertLessThan(HookTimeouts.stopRead, TimeInterval(HookTimeouts.stopHookProcess))
}
/// Stop 的阶梯必须**远短于**审批阶梯。
///
/// 它挡在每一次收工前面。挂到审批那条 75 秒的阶梯上,Hub 一卡住
/// 用户每说一句话就要干等 75 秒 —— 那比这个功能本身值钱得多。
func testTheStopLadderIsMuchShorterThanTheApprovalLadder() {
XCTAssertLessThan(HookTimeouts.stopRead, HookTimeouts.clientRead / 5)
XCTAssertLessThanOrEqual(HookTimeouts.stopHookProcess, 15)
}
}
@@ -0,0 +1,98 @@
import XCTest
@testable import HubUI
/// 解析 Claude 的逐条自查回答。
///
/// 这段解析的输入是**一段长对话末尾的自由文本**,不是干净的 API 返回。
/// 模型在这个位置加客套话、改格式、只答一半都是常态。解析失败是静默的
/// (表现为"清单一直没进展"),所以每种畸形都要单独钉住。
final class AcceptanceClaimParsingTests: XCTestCase {
private func parse(_ text: String) -> [AcceptanceClaim]? {
HookCoordinator.parseClaims(text)
}
// MARK: - 正常与包装
func testParsesBareJSON() {
let claims = parse(#"{"items":[{"id":"a1","done":true,"evidence":"改了 x.swift"}]}"#)
XCTAssertEqual(claims?.count, 1)
XCTAssertEqual(claims?.first?.id, "a1")
XCTAssertEqual(claims?.first?.done, true)
XCTAssertEqual(claims?.first?.evidence, "改了 x.swift")
}
func testStripsCodeFence() {
let text = """
```json
{"items":[{"id":"a1","done":true,"evidence":"改了 x.swift"}]}
```
"""
XCTAssertEqual(parse(text)?.count, 1)
}
/// 模型在 JSON 前后写一段话是常态,尤其是在长对话末尾。
func testToleratesProseAroundTheJSON() {
let text = """
我逐条核对了一遍:
```json
{"items":[{"id":"a1","done":false,"evidence":"这条没动"}]}
```
要我现在把没做的补上吗?
"""
XCTAssertEqual(parse(text)?.first?.done, false)
}
// MARK: - 含糊一律按「没做」算
/// **`done` 缺失时必须按没做算。**
///
/// 方向是刻意的:把含糊不清当成"做完了",等于给虚报开了个免检通道 ——
/// 模型只要省略这个字段就能让一条要点被标记成已完成。
/// 而这个功能存在的全部理由就是不给虚报留口子。
func testMissingDoneCountsAsNotDone() {
XCTAssertEqual(parse(#"{"items":[{"id":"a1","evidence":"呃"}]}"#)?.first?.done, false)
}
/// `done` 不是布尔(字符串 "true"、数字 1)同样按没做算。
func testNonBooleanDoneCountsAsNotDone() {
XCTAssertEqual(parse(#"{"items":[{"id":"a1","done":"true"}]}"#)?.first?.done, false)
XCTAssertEqual(parse(#"{"items":[{"id":"a1","done":1}]}"#)?.first?.done, false)
}
// MARK: - 畸形输入不能崩也不能误判
/// 没有 id 的条目对不上号,只能丢掉 —— 但不能连累同一批里正常的那些。
func testSkipsEntriesWithoutAnID() {
let claims = parse(#"{"items":[{"done":true},{"id":"","done":true},{"id":"ok","done":true}]}"#)
XCTAssertEqual(claims?.map(\.id), ["ok"])
}
/// 完全不是 JSON —— 它多半是直接去补做遗漏项了(那其实是好事)。
/// 返回 nil 让调用方什么都别做,退回旁路复核。
func testProseOnlyReplyYieldsNil() {
XCTAssertNil(parse("好的,我把漏掉的两条补上了,现在都跑通了。"))
}
func testMissingItemsKeyYieldsNil() {
XCTAssertNil(parse(#"{"points":[{"id":"a1"}]}"#))
}
func testEmptyReplyYieldsNil() {
XCTAssertNil(parse(""))
}
/// 空数组是"跑通了但一条都没答" —— 和解析失败不一样,
/// 但调用方对两者都是什么都不做,所以这里只要求不崩。
func testEmptyItemsArrayIsHandled() {
XCTAssertEqual(parse(#"{"items":[]}"#)?.isEmpty, true)
}
func testMissingEvidenceBecomesEmptyString() {
XCTAssertEqual(parse(#"{"items":[{"id":"a1","done":true}]}"#)?.first?.evidence, "")
}
}
+21 -4
View File
@@ -62,8 +62,8 @@ final class HookDedupTests: XCTestCase {
func testDifferentSessionsAreNotDuplicates() throws {
let dedup = HookDedup()
let a = try XCTUnwrap(HookDedup.key(for: event(.stop, session: "a", lastMessage: "好了")))
let b = try XCTUnwrap(HookDedup.key(for: event(.stop, session: "b", lastMessage: "好了")))
let a = try XCTUnwrap(HookDedup.key(for: event(.sessionEnd, session: "a")))
let b = try XCTUnwrap(HookDedup.key(for: event(.sessionEnd, session: "b")))
XCTAssertNotEqual(a, b)
guard case .first = dedup.begin(a, waitForDecision: false) else {
@@ -74,6 +74,23 @@ final class HookDedupTests: XCTestCase {
}
}
/// **收工事件刻意不去重。**
///
/// 缓存决策只有在决策是事件的**纯函数**时才成立。preToolUse 满足
/// (同一次工具调用永远同一个结论);stop 不满足 —— 它取决于会变的上膛状态。
///
/// 这条来自一次真实的端到端失败:先发一条未上膛的 stop(不拦),
/// 再上膛、再发一条内容一模一样的 stop —— 第二条被当成副本,
/// 复用了"不拦"的结论,**拦截静默失效**。
///
/// 把 discriminator 改回 `lastAssistantMessage`,这条必须变红。
func testStopIsDeliberatelyNotDeduplicated() {
XCTAssertNil(
HookDedup.key(for: event(.stop, lastMessage: "做完了")),
"stop 的结论依赖上膛状态,不是事件的纯函数,不能缓存"
)
}
/// 时间窗外的同样内容不算重复。
///
/// 用户隔一会儿又发一遍「继续」是正常的,那是**两次**真实输入,
@@ -165,8 +182,8 @@ final class HookDedupTests: XCTestCase {
/// 算不出来(返回 nil)意味着那一类完全不去重 —— 加新 kind 时最容易漏的
/// 就是这里,而漏了不会报错,只会在某天变成"通知弹两遍"。
func testEveryNonToolKindProducesAKey() {
let toolKinds: Set<HookEvent.Kind> = [.preToolUse, .postToolUse]
for kind in HookChannelMonitor.expected where !toolKinds.contains(kind) {
let exempt: Set<HookEvent.Kind> = [.preToolUse, .postToolUse, .stop]
for kind in HookChannelMonitor.expected where !exempt.contains(kind) {
XCTAssertNotNil(
HookDedup.key(for: event(kind)),
"\(kind.rawValue) 算不出去重 key —— 加新 kind 时漏了 HookDedup.key"
+16 -12
View File
@@ -82,30 +82,34 @@ if settings.get("autoMemoryDirectory") != "./.claude/memory":
changed.append("补上 autoMemoryDirectory")
# ── hook ──────────────────────────────────────────────────────
# (事件, hubctl 子命令, 阻塞时的 timeout;None = 非阻塞)
#
# Stop 的 10 秒和 PreToolUse 的 90 秒必须和 HubIPC/Timeouts.swift 里的阶梯一致。
# Stop 挡在每一次收工前面,用 90 秒会让 Hub 一卡住用户就干等。
HOOKS = [
("SessionStart", "sessionstart", False),
("UserPromptSubmit", "userprompt", False),
("PreToolUse", "pretool", True),
("PostToolUse", "posttool", False),
("Notification", "notification", False),
("Stop", "stop", False),
("SubagentStop", "subagentstop", False),
("PreCompact", "precompact", False),
("SessionEnd", "sessionend", False),
("SessionStart", "sessionstart", None),
("UserPromptSubmit", "userprompt", None),
("PreToolUse", "pretool", 90),
("PostToolUse", "posttool", None),
("Notification", "notification", None),
("Stop", "stop", 10),
("SubagentStop", "subagentstop", None),
("PreCompact", "precompact", None),
("SessionEnd", "sessionend", None),
]
if with_hooks:
hooks = settings.setdefault("hooks", {})
fixed = 0
for event, argument, blocking in HOOKS:
for event, argument, timeout in HOOKS:
command = f"{hubctl} hook {argument}"
entries = [
entry for entry in (hooks.get(event) or [])
if not any("hubctl hook" in h.get("command", "") for h in entry.get("hooks", []))
]
hook = {"type": "command", "command": command}
if blocking:
hook["timeout"] = 90
if timeout is not None:
hook["timeout"] = timeout
else:
hook["async"] = True
entries.append({"hooks": [hook]})
+8 -3
View File
@@ -79,7 +79,7 @@ def strip_legacy(entries):
kept.append(entry)
return kept
def install(event, argument, *, blocking):
def install(event, argument, *, blocking, timeout=90):
entries = strip_legacy(hooks.get(event))
hook = {"type": "command", "command": f"{hubctl} hook {argument}"}
if blocking:
@@ -88,7 +88,7 @@ def install(event, argument, *, blocking):
# 90 秒是超时阶梯(HookTimeouts)的最外层,必须大于 hubctl 的 75 秒读超时:
# 如果 Claude 在 hubctl 输出拒绝之前就把它杀掉,那等同于没有输出,
# 也就是**放行** —— 正好是安全默认值的反面。
hook["timeout"] = 90
hook["timeout"] = timeout
else:
# async: true 很关键 —— 旧配置是 false 且 hook 里同步 afplay,
# 导致每轮回复结束都被阻塞。
@@ -110,7 +110,12 @@ install("UserPromptSubmit", "userprompt", blocking=False)
install("PreToolUse", "pretool", blocking=True)
install("PostToolUse", "posttool", blocking=False)
install("Notification", "notification", blocking=False)
install("Stop", "stop", blocking=False)
# Stop 是阻塞的:Hub 要在这里判断该不该把验收清单塞回去。
#
# 10 秒而不是 90 —— 它挡在**每一次收工**前面,Hub 一卡住用户就干等。
# 判断本身是查内存,毫秒级;这 10 秒纯粹是异常时的上界。
# 超时被杀掉等同于没有输出,也就是正常收工,方向是安全的。
install("Stop", "stop", blocking=True, timeout=10)
install("SubagentStop", "subagentstop", blocking=False)
install("PreCompact", "precompact", blocking=False)
install("SessionEnd", "sessionend", blocking=False)