/ utils / swarm / permissionSync.ts
permissionSync.ts
  1  /**
  2   * Synchronized Permission Prompts for Agent Swarms
  3   *
  4   * This module provides infrastructure for coordinating permission prompts across
  5   * multiple agents in a swarm. When a worker agent needs permission for a tool use,
  6   * it can forward the request to the team leader, who can then approve or deny it.
  7   *
  8   * The system uses the teammate mailbox for message passing:
  9   * - Workers send permission requests to the leader's mailbox
 10   * - Leaders send permission responses to the worker's mailbox
 11   *
 12   * Flow:
 13   * 1. Worker agent encounters a permission prompt
 14   * 2. Worker sends a permission_request message to the leader's mailbox
 15   * 3. Leader polls for mailbox messages and detects permission requests
 16   * 4. User approves/denies via the leader's UI
 17   * 5. Leader sends a permission_response message to the worker's mailbox
 18   * 6. Worker polls mailbox for responses and continues execution
 19   */
 20  
 21  import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises'
 22  import { join } from 'path'
 23  import { z } from 'zod/v4'
 24  import { logForDebugging } from '../debug.js'
 25  import { getErrnoCode } from '../errors.js'
 26  import { lazySchema } from '../lazySchema.js'
 27  import * as lockfile from '../lockfile.js'
 28  import { logError } from '../log.js'
 29  import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js'
 30  import { jsonParse, jsonStringify } from '../slowOperations.js'
 31  import {
 32    getAgentId,
 33    getAgentName,
 34    getTeammateColor,
 35    getTeamName,
 36  } from '../teammate.js'
 37  import {
 38    createPermissionRequestMessage,
 39    createPermissionResponseMessage,
 40    createSandboxPermissionRequestMessage,
 41    createSandboxPermissionResponseMessage,
 42    writeToMailbox,
 43  } from '../teammateMailbox.js'
 44  import { getTeamDir, readTeamFileAsync } from './teamHelpers.js'
 45  
 46  /**
 47   * Full request schema for a permission request from a worker to the leader
 48   */
 49  export const SwarmPermissionRequestSchema = lazySchema(() =>
 50    z.object({
 51      /** Unique identifier for this request */
 52      id: z.string(),
 53      /** Worker's CLAUDE_CODE_AGENT_ID */
 54      workerId: z.string(),
 55      /** Worker's CLAUDE_CODE_AGENT_NAME */
 56      workerName: z.string(),
 57      /** Worker's CLAUDE_CODE_AGENT_COLOR */
 58      workerColor: z.string().optional(),
 59      /** Team name for routing */
 60      teamName: z.string(),
 61      /** Tool name requiring permission (e.g., "Bash", "Edit") */
 62      toolName: z.string(),
 63      /** Original toolUseID from worker's context */
 64      toolUseId: z.string(),
 65      /** Human-readable description of the tool use */
 66      description: z.string(),
 67      /** Serialized tool input */
 68      input: z.record(z.string(), z.unknown()),
 69      /** Suggested permission rules from the permission result */
 70      permissionSuggestions: z.array(z.unknown()),
 71      /** Status of the request */
 72      status: z.enum(['pending', 'approved', 'rejected']),
 73      /** Who resolved the request */
 74      resolvedBy: z.enum(['worker', 'leader']).optional(),
 75      /** Timestamp when resolved */
 76      resolvedAt: z.number().optional(),
 77      /** Rejection feedback message */
 78      feedback: z.string().optional(),
 79      /** Modified input if changed by resolver */
 80      updatedInput: z.record(z.string(), z.unknown()).optional(),
 81      /** "Always allow" rules applied during resolution */
 82      permissionUpdates: z.array(z.unknown()).optional(),
 83      /** Timestamp when request was created */
 84      createdAt: z.number(),
 85    }),
 86  )
 87  
 88  export type SwarmPermissionRequest = z.infer<
 89    ReturnType<typeof SwarmPermissionRequestSchema>
 90  >
 91  
 92  /**
 93   * Resolution data returned when leader/worker resolves a request
 94   */
 95  export type PermissionResolution = {
 96    /** Decision: approved or rejected */
 97    decision: 'approved' | 'rejected'
 98    /** Who resolved it */
 99    resolvedBy: 'worker' | 'leader'
100    /** Optional feedback message if rejected */
101    feedback?: string
102    /** Optional updated input if the resolver modified it */
103    updatedInput?: Record<string, unknown>
104    /** Permission updates to apply (e.g., "always allow" rules) */
105    permissionUpdates?: PermissionUpdate[]
106  }
107  
108  /**
109   * Get the base directory for a team's permission requests
110   * Path: ~/.claude/teams/{teamName}/permissions/
111   */
112  export function getPermissionDir(teamName: string): string {
113    return join(getTeamDir(teamName), 'permissions')
114  }
115  
116  /**
117   * Get the pending directory for a team
118   */
119  function getPendingDir(teamName: string): string {
120    return join(getPermissionDir(teamName), 'pending')
121  }
122  
123  /**
124   * Get the resolved directory for a team
125   */
126  function getResolvedDir(teamName: string): string {
127    return join(getPermissionDir(teamName), 'resolved')
128  }
129  
130  /**
131   * Ensure the permissions directory structure exists (async)
132   */
133  async function ensurePermissionDirsAsync(teamName: string): Promise<void> {
134    const permDir = getPermissionDir(teamName)
135    const pendingDir = getPendingDir(teamName)
136    const resolvedDir = getResolvedDir(teamName)
137  
138    for (const dir of [permDir, pendingDir, resolvedDir]) {
139      await mkdir(dir, { recursive: true })
140    }
141  }
142  
143  /**
144   * Get the path to a pending request file
145   */
146  function getPendingRequestPath(teamName: string, requestId: string): string {
147    return join(getPendingDir(teamName), `${requestId}.json`)
148  }
149  
150  /**
151   * Get the path to a resolved request file
152   */
153  function getResolvedRequestPath(teamName: string, requestId: string): string {
154    return join(getResolvedDir(teamName), `${requestId}.json`)
155  }
156  
157  /**
158   * Generate a unique request ID
159   */
160  export function generateRequestId(): string {
161    return `perm-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
162  }
163  
164  /**
165   * Create a new SwarmPermissionRequest object
166   */
167  export function createPermissionRequest(params: {
168    toolName: string
169    toolUseId: string
170    input: Record<string, unknown>
171    description: string
172    permissionSuggestions?: unknown[]
173    teamName?: string
174    workerId?: string
175    workerName?: string
176    workerColor?: string
177  }): SwarmPermissionRequest {
178    const teamName = params.teamName || getTeamName()
179    const workerId = params.workerId || getAgentId()
180    const workerName = params.workerName || getAgentName()
181    const workerColor = params.workerColor || getTeammateColor()
182  
183    if (!teamName) {
184      throw new Error('Team name is required for permission requests')
185    }
186    if (!workerId) {
187      throw new Error('Worker ID is required for permission requests')
188    }
189    if (!workerName) {
190      throw new Error('Worker name is required for permission requests')
191    }
192  
193    return {
194      id: generateRequestId(),
195      workerId,
196      workerName,
197      workerColor,
198      teamName,
199      toolName: params.toolName,
200      toolUseId: params.toolUseId,
201      description: params.description,
202      input: params.input,
203      permissionSuggestions: params.permissionSuggestions || [],
204      status: 'pending',
205      createdAt: Date.now(),
206    }
207  }
208  
209  /**
210   * Write a permission request to the pending directory with file locking
211   * Called by worker agents when they need permission approval from the leader
212   *
213   * @returns The written request
214   */
215  export async function writePermissionRequest(
216    request: SwarmPermissionRequest,
217  ): Promise<SwarmPermissionRequest> {
218    await ensurePermissionDirsAsync(request.teamName)
219  
220    const pendingPath = getPendingRequestPath(request.teamName, request.id)
221    const lockDir = getPendingDir(request.teamName)
222  
223    // Create a directory-level lock file for atomic writes
224    const lockFilePath = join(lockDir, '.lock')
225    await writeFile(lockFilePath, '', 'utf-8')
226  
227    let release: (() => Promise<void>) | undefined
228    try {
229      release = await lockfile.lock(lockFilePath)
230  
231      // Write the request file
232      await writeFile(pendingPath, jsonStringify(request, null, 2), 'utf-8')
233  
234      logForDebugging(
235        `[PermissionSync] Wrote pending request ${request.id} from ${request.workerName} for ${request.toolName}`,
236      )
237  
238      return request
239    } catch (error) {
240      logForDebugging(
241        `[PermissionSync] Failed to write permission request: ${error}`,
242      )
243      logError(error)
244      throw error
245    } finally {
246      if (release) {
247        await release()
248      }
249    }
250  }
251  
252  /**
253   * Read all pending permission requests for a team
254   * Called by the team leader to see what requests need attention
255   */
256  export async function readPendingPermissions(
257    teamName?: string,
258  ): Promise<SwarmPermissionRequest[]> {
259    const team = teamName || getTeamName()
260    if (!team) {
261      logForDebugging('[PermissionSync] No team name available')
262      return []
263    }
264  
265    const pendingDir = getPendingDir(team)
266  
267    let files: string[]
268    try {
269      files = await readdir(pendingDir)
270    } catch (e: unknown) {
271      const code = getErrnoCode(e)
272      if (code === 'ENOENT') {
273        return []
274      }
275      logForDebugging(`[PermissionSync] Failed to read pending requests: ${e}`)
276      logError(e)
277      return []
278    }
279  
280    const jsonFiles = files.filter(f => f.endsWith('.json') && f !== '.lock')
281  
282    const results = await Promise.all(
283      jsonFiles.map(async file => {
284        const filePath = join(pendingDir, file)
285        try {
286          const content = await readFile(filePath, 'utf-8')
287          const parsed = SwarmPermissionRequestSchema().safeParse(
288            jsonParse(content),
289          )
290          if (parsed.success) {
291            return parsed.data
292          }
293          logForDebugging(
294            `[PermissionSync] Invalid request file ${file}: ${parsed.error.message}`,
295          )
296          return null
297        } catch (err) {
298          logForDebugging(
299            `[PermissionSync] Failed to read request file ${file}: ${err}`,
300          )
301          return null
302        }
303      }),
304    )
305  
306    const requests = results.filter(r => r !== null)
307  
308    // Sort by creation time (oldest first)
309    requests.sort((a, b) => a.createdAt - b.createdAt)
310  
311    return requests
312  }
313  
314  /**
315   * Read a resolved permission request by ID
316   * Called by workers to check if their request has been resolved
317   *
318   * @returns The resolved request, or null if not yet resolved
319   */
320  export async function readResolvedPermission(
321    requestId: string,
322    teamName?: string,
323  ): Promise<SwarmPermissionRequest | null> {
324    const team = teamName || getTeamName()
325    if (!team) {
326      return null
327    }
328  
329    const resolvedPath = getResolvedRequestPath(team, requestId)
330  
331    try {
332      const content = await readFile(resolvedPath, 'utf-8')
333      const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content))
334      if (parsed.success) {
335        return parsed.data
336      }
337      logForDebugging(
338        `[PermissionSync] Invalid resolved request ${requestId}: ${parsed.error.message}`,
339      )
340      return null
341    } catch (e: unknown) {
342      const code = getErrnoCode(e)
343      if (code === 'ENOENT') {
344        return null
345      }
346      logForDebugging(
347        `[PermissionSync] Failed to read resolved request ${requestId}: ${e}`,
348      )
349      logError(e)
350      return null
351    }
352  }
353  
354  /**
355   * Resolve a permission request
356   * Called by the team leader (or worker in self-resolution cases)
357   *
358   * Writes the resolution to resolved/, removes from pending/
359   */
360  export async function resolvePermission(
361    requestId: string,
362    resolution: PermissionResolution,
363    teamName?: string,
364  ): Promise<boolean> {
365    const team = teamName || getTeamName()
366    if (!team) {
367      logForDebugging('[PermissionSync] No team name available')
368      return false
369    }
370  
371    await ensurePermissionDirsAsync(team)
372  
373    const pendingPath = getPendingRequestPath(team, requestId)
374    const resolvedPath = getResolvedRequestPath(team, requestId)
375    const lockFilePath = join(getPendingDir(team), '.lock')
376  
377    await writeFile(lockFilePath, '', 'utf-8')
378  
379    let release: (() => Promise<void>) | undefined
380    try {
381      release = await lockfile.lock(lockFilePath)
382  
383      // Read the pending request
384      let content: string
385      try {
386        content = await readFile(pendingPath, 'utf-8')
387      } catch (e: unknown) {
388        const code = getErrnoCode(e)
389        if (code === 'ENOENT') {
390          logForDebugging(
391            `[PermissionSync] Pending request not found: ${requestId}`,
392          )
393          return false
394        }
395        throw e
396      }
397  
398      const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content))
399      if (!parsed.success) {
400        logForDebugging(
401          `[PermissionSync] Invalid pending request ${requestId}: ${parsed.error.message}`,
402        )
403        return false
404      }
405  
406      const request = parsed.data
407  
408      // Update the request with resolution data
409      const resolvedRequest: SwarmPermissionRequest = {
410        ...request,
411        status: resolution.decision === 'approved' ? 'approved' : 'rejected',
412        resolvedBy: resolution.resolvedBy,
413        resolvedAt: Date.now(),
414        feedback: resolution.feedback,
415        updatedInput: resolution.updatedInput,
416        permissionUpdates: resolution.permissionUpdates,
417      }
418  
419      // Write to resolved directory
420      await writeFile(
421        resolvedPath,
422        jsonStringify(resolvedRequest, null, 2),
423        'utf-8',
424      )
425  
426      // Remove from pending directory
427      await unlink(pendingPath)
428  
429      logForDebugging(
430        `[PermissionSync] Resolved request ${requestId} with ${resolution.decision}`,
431      )
432  
433      return true
434    } catch (error) {
435      logForDebugging(`[PermissionSync] Failed to resolve request: ${error}`)
436      logError(error)
437      return false
438    } finally {
439      if (release) {
440        await release()
441      }
442    }
443  }
444  
445  /**
446   * Clean up old resolved permission files
447   * Called periodically to prevent file accumulation
448   *
449   * @param teamName - Team name
450   * @param maxAgeMs - Maximum age in milliseconds (default: 1 hour)
451   */
452  export async function cleanupOldResolutions(
453    teamName?: string,
454    maxAgeMs = 3600000,
455  ): Promise<number> {
456    const team = teamName || getTeamName()
457    if (!team) {
458      return 0
459    }
460  
461    const resolvedDir = getResolvedDir(team)
462  
463    let files: string[]
464    try {
465      files = await readdir(resolvedDir)
466    } catch (e: unknown) {
467      const code = getErrnoCode(e)
468      if (code === 'ENOENT') {
469        return 0
470      }
471      logForDebugging(`[PermissionSync] Failed to cleanup resolutions: ${e}`)
472      logError(e)
473      return 0
474    }
475  
476    const now = Date.now()
477    const jsonFiles = files.filter(f => f.endsWith('.json'))
478  
479    const cleanupResults = await Promise.all(
480      jsonFiles.map(async file => {
481        const filePath = join(resolvedDir, file)
482        try {
483          const content = await readFile(filePath, 'utf-8')
484          const request = jsonParse(content) as SwarmPermissionRequest
485  
486          // Check if the resolution is old enough to clean up
487          // Use >= to handle edge case where maxAgeMs is 0 (clean up everything)
488          const resolvedAt = request.resolvedAt || request.createdAt
489          if (now - resolvedAt >= maxAgeMs) {
490            await unlink(filePath)
491            logForDebugging(`[PermissionSync] Cleaned up old resolution: ${file}`)
492            return 1
493          }
494          return 0
495        } catch {
496          // If we can't parse it, clean it up anyway
497          try {
498            await unlink(filePath)
499            return 1
500          } catch {
501            // Ignore deletion errors
502            return 0
503          }
504        }
505      }),
506    )
507  
508    const cleanedCount = cleanupResults.reduce<number>((sum, n) => sum + n, 0)
509  
510    if (cleanedCount > 0) {
511      logForDebugging(
512        `[PermissionSync] Cleaned up ${cleanedCount} old resolutions`,
513      )
514    }
515  
516    return cleanedCount
517  }
518  
519  /**
520   * Legacy response type for worker polling
521   * Used for backward compatibility with worker integration code
522   */
523  export type PermissionResponse = {
524    /** ID of the request this responds to */
525    requestId: string
526    /** Decision: approved or denied */
527    decision: 'approved' | 'denied'
528    /** Timestamp when response was created */
529    timestamp: string
530    /** Optional feedback message if denied */
531    feedback?: string
532    /** Optional updated input if the resolver modified it */
533    updatedInput?: Record<string, unknown>
534    /** Permission updates to apply (e.g., "always allow" rules) */
535    permissionUpdates?: unknown[]
536  }
537  
538  /**
539   * Poll for a permission response (worker-side convenience function)
540   * Converts the resolved request into a simpler response format
541   *
542   * @returns The permission response, or null if not yet resolved
543   */
544  export async function pollForResponse(
545    requestId: string,
546    _agentName?: string,
547    teamName?: string,
548  ): Promise<PermissionResponse | null> {
549    const resolved = await readResolvedPermission(requestId, teamName)
550    if (!resolved) {
551      return null
552    }
553  
554    return {
555      requestId: resolved.id,
556      decision: resolved.status === 'approved' ? 'approved' : 'denied',
557      timestamp: resolved.resolvedAt
558        ? new Date(resolved.resolvedAt).toISOString()
559        : new Date(resolved.createdAt).toISOString(),
560      feedback: resolved.feedback,
561      updatedInput: resolved.updatedInput,
562      permissionUpdates: resolved.permissionUpdates,
563    }
564  }
565  
566  /**
567   * Remove a worker's response after processing
568   * This is an alias for deleteResolvedPermission for backward compatibility
569   */
570  export async function removeWorkerResponse(
571    requestId: string,
572    _agentName?: string,
573    teamName?: string,
574  ): Promise<void> {
575    await deleteResolvedPermission(requestId, teamName)
576  }
577  
578  /**
579   * Check if the current agent is a team leader
580   */
581  export function isTeamLeader(teamName?: string): boolean {
582    const team = teamName || getTeamName()
583    if (!team) {
584      return false
585    }
586  
587    // Team leaders don't have an agent ID set, or their ID is 'team-lead'
588    const agentId = getAgentId()
589  
590    return !agentId || agentId === 'team-lead'
591  }
592  
593  /**
594   * Check if the current agent is a worker in a swarm
595   */
596  export function isSwarmWorker(): boolean {
597    const teamName = getTeamName()
598    const agentId = getAgentId()
599  
600    return !!teamName && !!agentId && !isTeamLeader()
601  }
602  
603  /**
604   * Delete a resolved permission file
605   * Called after a worker has processed the resolution
606   */
607  export async function deleteResolvedPermission(
608    requestId: string,
609    teamName?: string,
610  ): Promise<boolean> {
611    const team = teamName || getTeamName()
612    if (!team) {
613      return false
614    }
615  
616    const resolvedPath = getResolvedRequestPath(team, requestId)
617  
618    try {
619      await unlink(resolvedPath)
620      logForDebugging(
621        `[PermissionSync] Deleted resolved permission: ${requestId}`,
622      )
623      return true
624    } catch (e: unknown) {
625      const code = getErrnoCode(e)
626      if (code === 'ENOENT') {
627        return false
628      }
629      logForDebugging(
630        `[PermissionSync] Failed to delete resolved permission: ${e}`,
631      )
632      logError(e)
633      return false
634    }
635  }
636  
637  /**
638   * Submit a permission request (alias for writePermissionRequest)
639   * Provided for backward compatibility with worker integration code
640   */
641  export const submitPermissionRequest = writePermissionRequest
642  
643  // ============================================================================
644  // Mailbox-Based Permission System
645  // ============================================================================
646  
647  /**
648   * Get the leader's name from the team file
649   * This is needed to send permission requests to the leader's mailbox
650   */
651  export async function getLeaderName(teamName?: string): Promise<string | null> {
652    const team = teamName || getTeamName()
653    if (!team) {
654      return null
655    }
656  
657    const teamFile = await readTeamFileAsync(team)
658    if (!teamFile) {
659      logForDebugging(`[PermissionSync] Team file not found for team: ${team}`)
660      return null
661    }
662  
663    const leadMember = teamFile.members.find(
664      m => m.agentId === teamFile.leadAgentId,
665    )
666    return leadMember?.name || 'team-lead'
667  }
668  
669  /**
670   * Send a permission request to the leader via mailbox.
671   * This is the new mailbox-based approach that replaces the file-based pending directory.
672   *
673   * @param request - The permission request to send
674   * @returns true if the message was sent successfully
675   */
676  export async function sendPermissionRequestViaMailbox(
677    request: SwarmPermissionRequest,
678  ): Promise<boolean> {
679    const leaderName = await getLeaderName(request.teamName)
680    if (!leaderName) {
681      logForDebugging(
682        `[PermissionSync] Cannot send permission request: leader name not found`,
683      )
684      return false
685    }
686  
687    try {
688      // Create the permission request message
689      const message = createPermissionRequestMessage({
690        request_id: request.id,
691        agent_id: request.workerName,
692        tool_name: request.toolName,
693        tool_use_id: request.toolUseId,
694        description: request.description,
695        input: request.input,
696        permission_suggestions: request.permissionSuggestions,
697      })
698  
699      // Send to leader's mailbox (routes to in-process or file-based based on recipient)
700      await writeToMailbox(
701        leaderName,
702        {
703          from: request.workerName,
704          text: jsonStringify(message),
705          timestamp: new Date().toISOString(),
706          color: request.workerColor,
707        },
708        request.teamName,
709      )
710  
711      logForDebugging(
712        `[PermissionSync] Sent permission request ${request.id} to leader ${leaderName} via mailbox`,
713      )
714      return true
715    } catch (error) {
716      logForDebugging(
717        `[PermissionSync] Failed to send permission request via mailbox: ${error}`,
718      )
719      logError(error)
720      return false
721    }
722  }
723  
724  /**
725   * Send a permission response to a worker via mailbox.
726   * This is the new mailbox-based approach that replaces the file-based resolved directory.
727   *
728   * @param workerName - The worker's name to send the response to
729   * @param resolution - The permission resolution
730   * @param requestId - The original request ID
731   * @param teamName - The team name
732   * @returns true if the message was sent successfully
733   */
734  export async function sendPermissionResponseViaMailbox(
735    workerName: string,
736    resolution: PermissionResolution,
737    requestId: string,
738    teamName?: string,
739  ): Promise<boolean> {
740    const team = teamName || getTeamName()
741    if (!team) {
742      logForDebugging(
743        `[PermissionSync] Cannot send permission response: team name not found`,
744      )
745      return false
746    }
747  
748    try {
749      // Create the permission response message
750      const message = createPermissionResponseMessage({
751        request_id: requestId,
752        subtype: resolution.decision === 'approved' ? 'success' : 'error',
753        error: resolution.feedback,
754        updated_input: resolution.updatedInput,
755        permission_updates: resolution.permissionUpdates,
756      })
757  
758      // Get the sender name (leader's name)
759      const senderName = getAgentName() || 'team-lead'
760  
761      // Send to worker's mailbox (routes to in-process or file-based based on recipient)
762      await writeToMailbox(
763        workerName,
764        {
765          from: senderName,
766          text: jsonStringify(message),
767          timestamp: new Date().toISOString(),
768        },
769        team,
770      )
771  
772      logForDebugging(
773        `[PermissionSync] Sent permission response for ${requestId} to worker ${workerName} via mailbox`,
774      )
775      return true
776    } catch (error) {
777      logForDebugging(
778        `[PermissionSync] Failed to send permission response via mailbox: ${error}`,
779      )
780      logError(error)
781      return false
782    }
783  }
784  
785  // ============================================================================
786  // Sandbox Permission Mailbox System
787  // ============================================================================
788  
789  /**
790   * Generate a unique sandbox permission request ID
791   */
792  export function generateSandboxRequestId(): string {
793    return `sandbox-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
794  }
795  
796  /**
797   * Send a sandbox permission request to the leader via mailbox.
798   * Called by workers when sandbox runtime needs network access approval.
799   *
800   * @param host - The host requesting network access
801   * @param requestId - Unique ID for this request
802   * @param teamName - Optional team name
803   * @returns true if the message was sent successfully
804   */
805  export async function sendSandboxPermissionRequestViaMailbox(
806    host: string,
807    requestId: string,
808    teamName?: string,
809  ): Promise<boolean> {
810    const team = teamName || getTeamName()
811    if (!team) {
812      logForDebugging(
813        `[PermissionSync] Cannot send sandbox permission request: team name not found`,
814      )
815      return false
816    }
817  
818    const leaderName = await getLeaderName(team)
819    if (!leaderName) {
820      logForDebugging(
821        `[PermissionSync] Cannot send sandbox permission request: leader name not found`,
822      )
823      return false
824    }
825  
826    const workerId = getAgentId()
827    const workerName = getAgentName()
828    const workerColor = getTeammateColor()
829  
830    if (!workerId || !workerName) {
831      logForDebugging(
832        `[PermissionSync] Cannot send sandbox permission request: worker ID or name not found`,
833      )
834      return false
835    }
836  
837    try {
838      const message = createSandboxPermissionRequestMessage({
839        requestId,
840        workerId,
841        workerName,
842        workerColor,
843        host,
844      })
845  
846      // Send to leader's mailbox (routes to in-process or file-based based on recipient)
847      await writeToMailbox(
848        leaderName,
849        {
850          from: workerName,
851          text: jsonStringify(message),
852          timestamp: new Date().toISOString(),
853          color: workerColor,
854        },
855        team,
856      )
857  
858      logForDebugging(
859        `[PermissionSync] Sent sandbox permission request ${requestId} for host ${host} to leader ${leaderName} via mailbox`,
860      )
861      return true
862    } catch (error) {
863      logForDebugging(
864        `[PermissionSync] Failed to send sandbox permission request via mailbox: ${error}`,
865      )
866      logError(error)
867      return false
868    }
869  }
870  
871  /**
872   * Send a sandbox permission response to a worker via mailbox.
873   * Called by the leader when approving/denying a sandbox network access request.
874   *
875   * @param workerName - The worker's name to send the response to
876   * @param requestId - The original request ID
877   * @param host - The host that was approved/denied
878   * @param allow - Whether the connection is allowed
879   * @param teamName - Optional team name
880   * @returns true if the message was sent successfully
881   */
882  export async function sendSandboxPermissionResponseViaMailbox(
883    workerName: string,
884    requestId: string,
885    host: string,
886    allow: boolean,
887    teamName?: string,
888  ): Promise<boolean> {
889    const team = teamName || getTeamName()
890    if (!team) {
891      logForDebugging(
892        `[PermissionSync] Cannot send sandbox permission response: team name not found`,
893      )
894      return false
895    }
896  
897    try {
898      const message = createSandboxPermissionResponseMessage({
899        requestId,
900        host,
901        allow,
902      })
903  
904      const senderName = getAgentName() || 'team-lead'
905  
906      // Send to worker's mailbox (routes to in-process or file-based based on recipient)
907      await writeToMailbox(
908        workerName,
909        {
910          from: senderName,
911          text: jsonStringify(message),
912          timestamp: new Date().toISOString(),
913        },
914        team,
915      )
916  
917      logForDebugging(
918        `[PermissionSync] Sent sandbox permission response for ${requestId} (host: ${host}, allow: ${allow}) to worker ${workerName} via mailbox`,
919      )
920      return true
921    } catch (error) {
922      logForDebugging(
923        `[PermissionSync] Failed to send sandbox permission response via mailbox: ${error}`,
924      )
925      logError(error)
926      return false
927    }
928  }