@@ -15,13 +15,21 @@ actor PermissionServer {
1515 private static let basePort : UInt16 = 19836
1616 private static let maxPort : UInt16 = 19846
1717 private static let timeoutSeconds : UInt64 = 300 // 5 minutes
18+ /// Upper bound on simultaneously-valid hook tokens. Each CLI launch mints one;
19+ /// the oldest is evicted past this cap — generous enough that no realistic
20+ /// number of concurrent agents ever loses a still-live token.
21+ private static let maxValidRunTokens = 64
1822
1923 // MARK: - Properties
2024
2125 private var listener : NWListener ?
2226 private( set) var port : UInt16 = PermissionServer . basePort
2327 private let appSecret = UUID ( ) . uuidString
24- private var runToken = UUID ( ) . uuidString
28+ /// Hook tokens currently accepted on the `/hook/pre-tool-use` path. Each CLI
29+ /// subprocess gets its own (see `mintRunToken`), so spawning a new agent never
30+ /// invalidates the hook URL of an agent already running. Ordered oldest-first;
31+ /// bounded by `maxValidRunTokens`.
32+ private var validRunTokens : [ String ] = [ ]
2533 private let logger = Logger ( subsystem: " com.claudework " , category: " PermissionServer " )
2634
2735 /// Resolved outcome for a pending hook: the permission decision plus an optional
@@ -163,6 +171,7 @@ actor PermissionServer {
163171 subscribers. removeAll ( )
164172 sessionRegistry. removeAll ( )
165173 sessionToolAllows. removeAll ( )
174+ validRunTokens. removeAll ( )
166175 bashCmdAllows = nil
167176 }
168177
@@ -248,7 +257,7 @@ actor PermissionServer {
248257 id: toolUseId,
249258 toolName: toolName,
250259 toolInput: toolInput,
251- runToken: runToken ,
260+ runToken: " " ,
252261 streamPermissionMode: mode,
253262 sessionId: sessionId
254263 )
@@ -305,20 +314,22 @@ actor PermissionServer {
305314 return nil
306315 }
307316
308- /// Refresh the run token (call at the start of each CLI session).
309- func refreshRunToken( ) {
310- runToken = UUID ( ) . uuidString
311- }
317+ // MARK: - Hook Settings
312318
313- /// The current run token for building the hook URL.
314- func currentRunToken( ) -> String {
315- runToken
319+ /// Mint a fresh run token and register it as valid. Each CLI subprocess gets
320+ /// its own, so spawning a new agent never invalidates the hook URL of an agent
321+ /// that is already running. Evicts the oldest token past `maxValidRunTokens`.
322+ private func mintRunToken( ) -> String {
323+ let token = UUID ( ) . uuidString
324+ validRunTokens. append ( token)
325+ if validRunTokens. count > Self . maxValidRunTokens {
326+ validRunTokens. removeFirst ( validRunTokens. count - Self. maxValidRunTokens)
327+ }
328+ return token
316329 }
317330
318- // MARK: - Hook Settings
319-
320331 /// Generate the hook settings JSON that should be passed to `claude --settings`.
321- func generateHookSettings( ) -> String {
332+ private func generateHookSettings( runToken : String ) -> String {
322333 let url = " http://127.0.0.1: \( port) /hook/pre-tool-use/ \( appSecret) / \( runToken) "
323334 let settings : [ String : Any ] = [
324335 " hooks " : [
@@ -343,9 +354,9 @@ actor PermissionServer {
343354 return json
344355 }
345356
346- /// Write hook settings to a temporary file and return its path.
357+ /// Mint a fresh run token, write hook settings to a temporary file, return its path.
347358 func writeHookSettingsFile( ) throws -> String {
348- let json = generateHookSettings ( )
359+ let json = generateHookSettings ( runToken : mintRunToken ( ) )
349360 let tempDir = FileManager . default. temporaryDirectory
350361 let filePath = tempDir. appendingPathComponent ( " claudework-hooks- \( UUID ( ) . uuidString) .json " )
351362 try json. write ( to: filePath, atomically: true , encoding: . utf8)
@@ -373,7 +384,7 @@ actor PermissionServer {
373384 components [ 0 ] == " hook " ,
374385 components [ 1 ] == " pre-tool-use " ,
375386 components [ 2 ] == appSecret,
376- components [ 3 ] == runToken else {
387+ validRunTokens . contains ( components [ 3 ] ) else {
377388 logger. warning ( " Invalid path or secret: \( path) " )
378389 await sendHTTPResponse ( connection, status: " 403 Forbidden " , body: #"{"error":"invalid path"}"# )
379390 return
@@ -397,7 +408,7 @@ actor PermissionServer {
397408 id: hookRequest. toolUseId,
398409 toolName: hookRequest. toolName,
399410 toolInput: hookRequest. toolInput,
400- runToken: runToken ,
411+ runToken: components [ 3 ] ,
401412 streamPermissionMode: streamMode,
402413 sessionId: hookRequest. sessionId
403414 )
0 commit comments