/** * Synchronized Permission Prompts for Agent Swarms * * This module provides infrastructure for coordinating permission prompts across * multiple agents in a swarm. When a worker agent needs permission for a tool use, * it can forward the request to the team leader, who can then approve or deny it. * * The system uses the teammate mailbox for message passing: * - Workers send permission requests to the leader's mailbox * - Leaders send permission responses to the worker's mailbox * * Flow: * 1. Worker agent encounters a permission prompt * 2. Worker sends a permission_request message to the leader's mailbox * 3. Leader polls for mailbox messages and detects permission requests * 4. User approves/denies via the leader's UI * 5. Leader sends a permission_response message to the worker's mailbox * 6. Worker polls mailbox for responses and continues execution */ import { logForDebugging } from '../debug.js' import { logError } from '../log.js' import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js' import { jsonStringify } from '../slowOperations.js' import { getAgentId, getAgentName, getTeammateColor, getTeamName, } from '../teammate.js' import { createPermissionRequestMessage, createPermissionResponseMessage, createSandboxPermissionRequestMessage, createSandboxPermissionResponseMessage, writeToMailbox, } from '../teammateMailbox.js' import { readTeamFileAsync } from './teamHelpers.js' export type SwarmPermissionRequest = { /** Unique identifier for this request */ id: string /** Worker's CLAUDE_CODE_AGENT_ID */ workerId: string /** Worker's CLAUDE_CODE_AGENT_NAME */ workerName: string /** Worker's CLAUDE_CODE_AGENT_COLOR */ workerColor?: string /** Team name for routing */ teamName: string /** Tool name requiring permission (e.g., "Bash", "Edit") */ toolName: string /** Original toolUseID from worker's context */ toolUseId: string /** Human-readable description of the tool use */ description: string /** Serialized tool input */ input: Record /** Suggested permission rules from the permission result */ permissionSuggestions: unknown[] /** Status of the request */ status: 'pending' | 'approved' | 'rejected' /** Who resolved the request */ resolvedBy?: 'worker' | 'leader' /** Timestamp when resolved */ resolvedAt?: number /** Rejection feedback message */ feedback?: string /** Modified input if changed by resolver */ updatedInput?: Record /** "Always allow" rules applied during resolution */ permissionUpdates?: unknown[] /** Timestamp when request was created */ createdAt: number } /** * Resolution data returned when leader/worker resolves a request */ export type PermissionResolution = { /** Decision: approved or rejected */ decision: 'approved' | 'rejected' /** Who resolved it */ resolvedBy: 'worker' | 'leader' /** Optional feedback message if rejected */ feedback?: string /** Optional updated input if the resolver modified it */ updatedInput?: Record /** Permission updates to apply (e.g., "always allow" rules) */ permissionUpdates?: PermissionUpdate[] } /** * Generate a unique request ID */ export function generateRequestId(): string { return `perm-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` } /** * Create a new SwarmPermissionRequest object */ export function createPermissionRequest(params: { toolName: string toolUseId: string input: Record description: string permissionSuggestions?: unknown[] teamName?: string workerId?: string workerName?: string workerColor?: string }): SwarmPermissionRequest { const teamName = params.teamName || getTeamName() const workerId = params.workerId || getAgentId() const workerName = params.workerName || getAgentName() const workerColor = params.workerColor || getTeammateColor() if (!teamName) { throw new Error('Team name is required for permission requests') } if (!workerId) { throw new Error('Worker ID is required for permission requests') } if (!workerName) { throw new Error('Worker name is required for permission requests') } return { id: generateRequestId(), workerId, workerName, workerColor, teamName, toolName: params.toolName, toolUseId: params.toolUseId, description: params.description, input: params.input, permissionSuggestions: params.permissionSuggestions || [], status: 'pending', createdAt: Date.now(), } } /** * Check if the current agent is a team leader */ export function isTeamLeader(teamName?: string): boolean { const team = teamName || getTeamName() if (!team) { return false } // Team leaders don't have an agent ID set, or their ID is 'team-lead' const agentId = getAgentId() return !agentId || agentId === 'team-lead' } /** * Check if the current agent is a worker in a swarm */ export function isSwarmWorker(): boolean { const teamName = getTeamName() const agentId = getAgentId() return !!teamName && !!agentId && !isTeamLeader() } // ============================================================================ // Mailbox-Based Permission System // ============================================================================ /** * Get the leader's name from the team file * This is needed to send permission requests to the leader's mailbox */ export async function getLeaderName(teamName?: string): Promise { const team = teamName || getTeamName() if (!team) { return null } const teamFile = await readTeamFileAsync(team) if (!teamFile) { logForDebugging(`[PermissionSync] Team file not found for team: ${team}`) return null } const leadMember = teamFile.members.find( m => m.agentId === teamFile.leadAgentId, ) return leadMember?.name || 'team-lead' } /** * Send a permission request to the leader via mailbox. * This is the new mailbox-based approach that replaces the file-based pending directory. * * @param request - The permission request to send * @returns true if the message was sent successfully */ export async function sendPermissionRequestViaMailbox( request: SwarmPermissionRequest, ): Promise { const leaderName = await getLeaderName(request.teamName) if (!leaderName) { logForDebugging( `[PermissionSync] Cannot send permission request: leader name not found`, ) return false } try { // Create the permission request message const message = createPermissionRequestMessage({ request_id: request.id, agent_id: request.workerName, tool_name: request.toolName, tool_use_id: request.toolUseId, description: request.description, input: request.input, permission_suggestions: request.permissionSuggestions, }) // Send to leader's mailbox (routes to in-process or file-based based on recipient) await writeToMailbox( leaderName, { from: request.workerName, text: jsonStringify(message), timestamp: new Date().toISOString(), color: request.workerColor, }, request.teamName, ) logForDebugging( `[PermissionSync] Sent permission request ${request.id} to leader ${leaderName} via mailbox`, ) return true } catch (error) { logForDebugging( `[PermissionSync] Failed to send permission request via mailbox: ${error}`, ) logError(error) return false } } /** * Send a permission response to a worker via mailbox. * This is the new mailbox-based approach that replaces the file-based resolved directory. * * @param workerName - The worker's name to send the response to * @param resolution - The permission resolution * @param requestId - The original request ID * @param teamName - The team name * @returns true if the message was sent successfully */ export async function sendPermissionResponseViaMailbox( workerName: string, resolution: PermissionResolution, requestId: string, teamName?: string, ): Promise { const team = teamName || getTeamName() if (!team) { logForDebugging( `[PermissionSync] Cannot send permission response: team name not found`, ) return false } try { // Create the permission response message const message = createPermissionResponseMessage({ request_id: requestId, subtype: resolution.decision === 'approved' ? 'success' : 'error', error: resolution.feedback, updated_input: resolution.updatedInput, permission_updates: resolution.permissionUpdates, }) // Get the sender name (leader's name) const senderName = getAgentName() || 'team-lead' // Send to worker's mailbox (routes to in-process or file-based based on recipient) await writeToMailbox( workerName, { from: senderName, text: jsonStringify(message), timestamp: new Date().toISOString(), }, team, ) logForDebugging( `[PermissionSync] Sent permission response for ${requestId} to worker ${workerName} via mailbox`, ) return true } catch (error) { logForDebugging( `[PermissionSync] Failed to send permission response via mailbox: ${error}`, ) logError(error) return false } } // ============================================================================ // Sandbox Permission Mailbox System // ============================================================================ /** * Generate a unique sandbox permission request ID */ export function generateSandboxRequestId(): string { return `sandbox-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` } /** * Send a sandbox permission request to the leader via mailbox. * Called by workers when sandbox runtime needs network access approval. * * @param host - The host requesting network access * @param requestId - Unique ID for this request * @param teamName - Optional team name * @returns true if the message was sent successfully */ export async function sendSandboxPermissionRequestViaMailbox( host: string, requestId: string, teamName?: string, ): Promise { const team = teamName || getTeamName() if (!team) { logForDebugging( `[PermissionSync] Cannot send sandbox permission request: team name not found`, ) return false } const leaderName = await getLeaderName(team) if (!leaderName) { logForDebugging( `[PermissionSync] Cannot send sandbox permission request: leader name not found`, ) return false } const workerId = getAgentId() const workerName = getAgentName() const workerColor = getTeammateColor() if (!workerId || !workerName) { logForDebugging( `[PermissionSync] Cannot send sandbox permission request: worker ID or name not found`, ) return false } try { const message = createSandboxPermissionRequestMessage({ requestId, workerId, workerName, workerColor, host, }) // Send to leader's mailbox (routes to in-process or file-based based on recipient) await writeToMailbox( leaderName, { from: workerName, text: jsonStringify(message), timestamp: new Date().toISOString(), color: workerColor, }, team, ) logForDebugging( `[PermissionSync] Sent sandbox permission request ${requestId} for host ${host} to leader ${leaderName} via mailbox`, ) return true } catch (error) { logForDebugging( `[PermissionSync] Failed to send sandbox permission request via mailbox: ${error}`, ) logError(error) return false } } /** * Send a sandbox permission response to a worker via mailbox. * Called by the leader when approving/denying a sandbox network access request. * * @param workerName - The worker's name to send the response to * @param requestId - The original request ID * @param host - The host that was approved/denied * @param allow - Whether the connection is allowed * @param teamName - Optional team name * @returns true if the message was sent successfully */ export async function sendSandboxPermissionResponseViaMailbox( workerName: string, requestId: string, host: string, allow: boolean, teamName?: string, ): Promise { const team = teamName || getTeamName() if (!team) { logForDebugging( `[PermissionSync] Cannot send sandbox permission response: team name not found`, ) return false } try { const message = createSandboxPermissionResponseMessage({ requestId, host, allow, }) const senderName = getAgentName() || 'team-lead' // Send to worker's mailbox (routes to in-process or file-based based on recipient) await writeToMailbox( workerName, { from: senderName, text: jsonStringify(message), timestamp: new Date().toISOString(), }, team, ) logForDebugging( `[PermissionSync] Sent sandbox permission response for ${requestId} (host: ${host}, allow: ${allow}) to worker ${workerName} via mailbox`, ) return true } catch (error) { logForDebugging( `[PermissionSync] Failed to send sandbox permission response via mailbox: ${error}`, ) logError(error) return false } }