diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 7460f160..c98f1197 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -5848,6 +5848,8 @@ export class TeamProvisioningService { receivedAt: messageTimestamp || new Date().toISOString(), teamColor: run.request.color, teamDisplayName: run.request.displayName, + permissionSuggestions: + perm.permissionSuggestions.length > 0 ? perm.permissionSuggestions : undefined, }; const autoResult = shouldAutoAllow( @@ -5859,7 +5861,14 @@ export class TeamProvisioningService { logger.info( `[${run.teamName}] Auto-allowing teammate ${perm.agentId} ${perm.toolName} (${autoResult.reason})` ); - void this.respondToTeammatePermission(run, perm.agentId, perm.requestId, true); + void this.respondToTeammatePermission( + run, + perm.agentId, + perm.requestId, + true, + undefined, + perm.permissionSuggestions + ); this.emitToolApprovalEvent({ autoResolved: true, requestId: perm.requestId, @@ -6046,14 +6055,14 @@ export class TeamProvisioningService { const approval = run.pendingApprovals.get(requestId); if (approval && approval.source !== 'lead') { - // Teammate request — respond via inbox + control_response fallback. - // Defer cleanup until the async write completes to avoid silent data loss. + // Teammate request — apply permission_suggestions to project settings. this.respondToTeammatePermission( run, approval.source, requestId, allow, - allow ? undefined : 'Timed out — auto-denied by settings' + allow ? undefined : 'Timed out — auto-denied by settings', + approval.permissionSuggestions ).finally(() => { run.pendingApprovals.delete(requestId); this.inFlightResponses.delete(requestId); @@ -6132,7 +6141,14 @@ export class TeamProvisioningService { this.clearApprovalTimeout(requestId); if (!this.tryClaimResponse(requestId)) continue; if (approval.source !== 'lead') { - void this.respondToTeammatePermission(run, approval.source, requestId, true); + void this.respondToTeammatePermission( + run, + approval.source, + requestId, + true, + undefined, + approval.permissionSuggestions + ); } else { this.autoAllowControlRequest(run, requestId); } @@ -6198,10 +6214,17 @@ export class TeamProvisioningService { const approval = run.pendingApprovals.get(requestId)!; - // Teammate permission requests use a different response path (inbox, not stdin) + // Teammate permission requests: apply permission_suggestions to project settings if (approval.source !== 'lead') { try { - await this.respondToTeammatePermission(run, approval.source, requestId, allow, message); + await this.respondToTeammatePermission( + run, + approval.source, + requestId, + allow, + message, + approval.permissionSuggestions + ); } finally { run.pendingApprovals.delete(requestId); this.inFlightResponses.delete(requestId); @@ -6284,92 +6307,133 @@ export class TeamProvisioningService { } /** - * Respond to a teammate's permission_request by writing to the teammate's inbox - * AND attempting a control_response via stdin (belt-and-suspenders). + * Respond to a teammate's permission_request by applying permission_suggestions. + * + * FACT: Claude Code teammate runtime sends permission_request via SendMessage (inbox protocol). + * FACT: Writing permission_response to teammate inbox does NOT work - runtime ignores it. + * FACT: control_response via stdin does NOT work for teammate requests - request_id doesn't match. + * FACT: permission_suggestions.destination "localSettings" refers to {cwd}/.claude/settings.local.json. + * FACT: Claude Code CLI reads this file via --setting-sources user,project,local. + * + * When allow=true: applies permission_suggestions (adds tool rules to project settings). + * When allow=false: no action needed - tool stays blocked by default. */ private async respondToTeammatePermission( run: ProvisioningRun, agentId: string, requestId: string, allow: boolean, - message?: string + _message?: string, + permissionSuggestions?: import('@shared/utils/inboxNoise').PermissionSuggestion[] ): Promise { - const teamsBase = getTeamsBasePath(); - const inboxPath = path.join(teamsBase, run.teamName, 'inboxes', `${agentId}.json`); + if (!allow) { + logger.info(`[${run.teamName}] Denied teammate ${agentId} permission ${requestId}`); + return; + } - // 1. Write permission_response to teammate's inbox (with proper file locking) - const responseMsg = { - from: 'user', - text: JSON.stringify({ - type: 'permission_response', - request_id: requestId, - approved: allow, - ...(message ? { message } : {}), - }), - timestamp: new Date().toISOString(), - read: false, - }; + // Apply permission_suggestions: add tool rules to project settings file + const suggestions = permissionSuggestions ?? []; + if (suggestions.length === 0) { + logger.warn(`[${run.teamName}] No permission_suggestions for ${requestId} — cannot add rule`); + return; + } + // Resolve project cwd from team config + let projectCwd: string | undefined; try { - await withFileLock(inboxPath, async () => { - await withInboxLock(inboxPath, async () => { - let existing: unknown[] = []; - try { - const raw = await tryReadRegularFileUtf8(inboxPath, { - timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, - maxBytes: TEAM_INBOX_MAX_BYTES, - }); - if (raw) { - const parsed = JSON.parse(raw) as unknown; - if (Array.isArray(parsed)) existing = parsed; - } - } catch { - // File may not exist yet — start with empty array - } - existing.push(responseMsg); - await atomicWriteAsync(inboxPath, JSON.stringify(existing, null, 2)); - }); - }); - logger.info( - `[${run.teamName}] Wrote permission_response to ${agentId} inbox: ${allow ? 'allow' : 'deny'}` - ); - } catch (error) { - logger.error( - `[${run.teamName}] Failed to write permission_response to ${agentId}: ${ - error instanceof Error ? error.message : String(error) - }` - ); + const config = await this.configReader.getConfig(run.teamName); + projectCwd = config?.projectPath ?? config?.members?.[0]?.cwd; + } catch { + // best-effort + } + if (!projectCwd) { + logger.warn(`[${run.teamName}] Cannot resolve project cwd for permission rule — skipping`); + return; } - // 2. Also try control_response via stdin (in case lead runtime can forward it) - if (run.child?.stdin?.writable) { - const controlResponse = allow - ? { - type: 'control_response', - response: { - subtype: 'success', - request_id: requestId, - response: { behavior: 'allow' }, - }, - } - : { - type: 'control_response', - response: { - subtype: 'success', - request_id: requestId, - response: { behavior: 'deny', message: message ?? 'User denied' }, - }, - }; - run.child.stdin.write(JSON.stringify(controlResponse) + '\n', (err) => { - if (err) { - logger.warn( - `[${run.teamName}] control_response via stdin for teammate ${agentId} failed (non-critical): ${err.message}` - ); - } - }); + for (const suggestion of suggestions) { + if (suggestion.type !== 'addRules' || !Array.isArray(suggestion.rules)) continue; + + const toolNames = suggestion.rules + .map((r) => r.toolName) + .filter((name): name is string => typeof name === 'string' && name.length > 0); + if (toolNames.length === 0) continue; + + const behavior = suggestion.behavior ?? 'allow'; + // FACT: observed destinations are "localSettings" (project-level .claude/settings.local.json) + const settingsPath = + suggestion.destination === 'localSettings' + ? path.join(projectCwd, '.claude', 'settings.local.json') + : path.join(projectCwd, '.claude', 'settings.local.json'); // default to local + + try { + await this.addPermissionRulesToSettings(settingsPath, toolNames, behavior); + logger.info( + `[${run.teamName}] Added permission rules for ${agentId}: ${toolNames.join(', ')} → ${behavior} in ${settingsPath}` + ); + } catch (error) { + logger.error( + `[${run.teamName}] Failed to add permission rules: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } } } + /** + * Safely add tool names to the permissions.allow (or deny) array in a Claude settings file. + * Creates the file and parent directories if they don't exist. + * Merges with existing entries — never overwrites. + */ + private async addPermissionRulesToSettings( + settingsPath: string, + toolNames: string[], + behavior: string + ): Promise { + const dir = path.dirname(settingsPath); + await fs.promises.mkdir(dir, { recursive: true }); + + // Read existing settings (or start with empty object) + let settings: Record = {}; + try { + const raw = await fs.promises.readFile(settingsPath, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + settings = parsed as Record; + } + } catch { + // File doesn't exist or invalid JSON — start fresh + } + + // Ensure permissions object exists + if (!settings.permissions || typeof settings.permissions !== 'object') { + settings.permissions = {}; + } + const perms = settings.permissions as Record; + + // Target array: "allow" or "deny" based on behavior + const key = behavior === 'deny' ? 'deny' : 'allow'; + if (!Array.isArray(perms[key])) { + perms[key] = []; + } + const list = perms[key] as string[]; + + // Add tool names that aren't already in the list + const existing = new Set(list); + let added = 0; + for (const name of toolNames) { + if (!existing.has(name)) { + list.push(name); + added++; + } + } + + if (added === 0) return; // Nothing new to add + + await fs.promises.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + } + /** * Called when the first stream-json turn completes successfully. * Verifies provisioning files exist and marks as ready. diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index fd51c499..b8f4e817 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -888,6 +888,15 @@ export interface ToolApprovalRequest { teamColor?: string; /** Team display name (from config or create request). */ teamDisplayName?: string; + /** Permission suggestions from teammate runtime (only for teammate permission_request). + * FACT: Populated by Claude Code runtime, contains instructions to add permission rules. + */ + permissionSuggestions?: { + type: string; + rules?: { toolName: string }[]; + behavior?: string; + destination?: string; + }[]; } /** Dismissal event — process died, all pending approvals for this team+run should be removed. */ diff --git a/src/shared/utils/inboxNoise.ts b/src/shared/utils/inboxNoise.ts index dff92e57..b6784819 100644 --- a/src/shared/utils/inboxNoise.ts +++ b/src/shared/utils/inboxNoise.ts @@ -41,6 +41,14 @@ export function isInboxNoiseMessage(text: string): boolean { // Teammate permission request parsing // --------------------------------------------------------------------------- +/** A single permission suggestion from the teammate runtime. */ +export interface PermissionSuggestion { + type: string; + rules?: { toolName: string }[]; + behavior?: string; + destination?: string; +} + /** Parsed teammate permission request from inbox message. */ export interface ParsedPermissionRequest { requestId: string; @@ -49,6 +57,11 @@ export interface ParsedPermissionRequest { toolUseId: string; description: string; input: Record; + /** Suggestions from teammate runtime on how to resolve the permission. + * FACT: This field is populated by Claude Code runtime, not by the AI agent. + * FACT: Observed format: { type: "addRules", rules: [{toolName}], behavior: "allow", destination: "localSettings" } + */ + permissionSuggestions: PermissionSuggestion[]; } /** @@ -75,6 +88,9 @@ export function parsePermissionRequest(text: string): ParsedPermissionRequest | parsed.input && typeof parsed.input === 'object' && !Array.isArray(parsed.input) ? (parsed.input as Record) : {}, + permissionSuggestions: Array.isArray(parsed.permission_suggestions) + ? (parsed.permission_suggestions as PermissionSuggestion[]) + : [], }; }