feat(team): per-team tool approval settings

Tool approval settings (autoAllowAll, autoAllowFileEdits, etc.) are
now stored per-team instead of globally. Clicking 'Allow all' in one
team no longer affects other teams.

- localStorage key: 'team:toolApprovalSettings:{teamName}'
- Settings loaded on team select, initialized on create/launch
- skipPermissions=false -> defaults (autoAllowAll:false)
- skipPermissions=true -> autoAllowAll:true for that team
- Main process: Map<teamName, settings> instead of single instance
- IPC: teamName parameter added to updateToolApprovalSettings
This commit is contained in:
iliya 2026-03-29 22:46:25 +03:00
parent 8c08087337
commit 58bd7cc507
6 changed files with 82 additions and 38 deletions

View file

@ -2945,8 +2945,12 @@ async function handleToolApprovalRespond(
async function handleToolApprovalSettings( async function handleToolApprovalSettings(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown,
settings: unknown settings: unknown
): Promise<IpcResult<void>> { ): Promise<IpcResult<void>> {
if (typeof teamName !== 'string' || teamName.trim().length === 0) {
return { success: false, error: 'teamName must be a non-empty string' };
}
if (typeof settings !== 'object' || settings === null) { if (typeof settings !== 'object' || settings === null) {
return { success: false, error: 'Settings must be an object' }; return { success: false, error: 'Settings must be an object' };
} }
@ -2973,7 +2977,10 @@ async function handleToolApprovalSettings(
} }
try { try {
getTeamProvisioningService().updateToolApprovalSettings(s as unknown as ToolApprovalSettings); getTeamProvisioningService().updateToolApprovalSettings(
teamName as string,
s as unknown as ToolApprovalSettings
);
} catch (err) { } catch (err) {
return { return {
success: false, success: false,

View file

@ -1276,7 +1276,7 @@ export class TeamProvisioningService {
private helpOutputCache: string | null = null; private helpOutputCache: string | null = null;
private helpOutputCacheTime = 0; private helpOutputCacheTime = 0;
private static readonly HELP_CACHE_TTL_MS = 5 * 60 * 1000; private static readonly HELP_CACHE_TTL_MS = 5 * 60 * 1000;
private toolApprovalSettings: ToolApprovalSettings = DEFAULT_TOOL_APPROVAL_SETTINGS; private toolApprovalSettingsByTeam = new Map<string, ToolApprovalSettings>();
private pendingTimeouts = new Map<string, NodeJS.Timeout>(); private pendingTimeouts = new Map<string, NodeJS.Timeout>();
private inFlightResponses = new Set<string>(); private inFlightResponses = new Set<string>();
private controlApiBaseUrlResolver: (() => Promise<string | null>) | null = null; private controlApiBaseUrlResolver: (() => Promise<string | null>) | null = null;
@ -2022,8 +2022,12 @@ export class TeamProvisioningService {
this.mainWindowRef = win; this.mainWindowRef = win;
} }
updateToolApprovalSettings(settings: ToolApprovalSettings): void { private getToolApprovalSettings(teamName: string): ToolApprovalSettings {
this.toolApprovalSettings = settings; return this.toolApprovalSettingsByTeam.get(teamName) ?? DEFAULT_TOOL_APPROVAL_SETTINGS;
}
updateToolApprovalSettings(teamName: string, settings: ToolApprovalSettings): void {
this.toolApprovalSettingsByTeam.set(teamName, settings);
this.reEvaluatePendingApprovals(); this.reEvaluatePendingApprovals();
} }
@ -5779,7 +5783,11 @@ export class TeamProvisioningService {
}; };
// Check auto-allow rules before prompting user // Check auto-allow rules before prompting user
const autoResult = shouldAutoAllow(this.toolApprovalSettings, toolName, toolInput); const autoResult = shouldAutoAllow(
this.getToolApprovalSettings(run.teamName),
toolName,
toolInput
);
if (autoResult.autoAllow) { if (autoResult.autoAllow) {
logger.info(`[${run.teamName}] Auto-allowing ${toolName} (${autoResult.reason})`); logger.info(`[${run.teamName}] Auto-allowing ${toolName} (${autoResult.reason})`);
this.autoAllowControlRequest(run, requestId); this.autoAllowControlRequest(run, requestId);
@ -5832,7 +5840,11 @@ export class TeamProvisioningService {
teamDisplayName: run.request.displayName, teamDisplayName: run.request.displayName,
}; };
const autoResult = shouldAutoAllow(this.toolApprovalSettings, perm.toolName, perm.input); const autoResult = shouldAutoAllow(
this.getToolApprovalSettings(run.teamName),
perm.toolName,
perm.input
);
if (autoResult.autoAllow) { if (autoResult.autoAllow) {
logger.info( logger.info(
`[${run.teamName}] Auto-allowing teammate ${perm.agentId} ${perm.toolName} (${autoResult.reason})` `[${run.teamName}] Auto-allowing teammate ${perm.agentId} ${perm.toolName} (${autoResult.reason})`
@ -6003,7 +6015,7 @@ export class TeamProvisioningService {
} }
private startApprovalTimeout(run: ProvisioningRun, requestId: string): void { private startApprovalTimeout(run: ProvisioningRun, requestId: string): void {
const { timeoutAction, timeoutSeconds } = this.toolApprovalSettings; const { timeoutAction, timeoutSeconds } = this.getToolApprovalSettings(run.teamName);
if (timeoutAction === 'wait') return; if (timeoutAction === 'wait') return;
const timeoutMs = timeoutSeconds * 1000; const timeoutMs = timeoutSeconds * 1000;
@ -6013,7 +6025,7 @@ export class TeamProvisioningService {
if (!this.tryClaimResponse(requestId)) return; if (!this.tryClaimResponse(requestId)) return;
// Read CURRENT settings (not captured closure) in case user changed action // Read CURRENT settings (not captured closure) in case user changed action
const currentAction = this.toolApprovalSettings.timeoutAction; const currentAction = this.getToolApprovalSettings(run.teamName).timeoutAction;
if (currentAction === 'wait') { if (currentAction === 'wait') {
// Settings changed to 'wait' but timer fired before reEvaluatePendingApprovals cleared it // Settings changed to 'wait' but timer fired before reEvaluatePendingApprovals cleared it
this.inFlightResponses.delete(requestId); this.inFlightResponses.delete(requestId);
@ -6102,13 +6114,10 @@ export class TeamProvisioningService {
private reEvaluatePendingApprovals(): void { private reEvaluatePendingApprovals(): void {
for (const [, run] of this.runs) { for (const [, run] of this.runs) {
const settings = this.getToolApprovalSettings(run.teamName);
const toRemove: string[] = []; const toRemove: string[] = [];
for (const [requestId, approval] of run.pendingApprovals) { for (const [requestId, approval] of run.pendingApprovals) {
const result = shouldAutoAllow( const result = shouldAutoAllow(settings, approval.toolName, approval.toolInput);
this.toolApprovalSettings,
approval.toolName,
approval.toolInput
);
if (result.autoAllow) { if (result.autoAllow) {
this.clearApprovalTimeout(requestId); this.clearApprovalTimeout(requestId);
if (!this.tryClaimResponse(requestId)) continue; if (!this.tryClaimResponse(requestId)) continue;
@ -6126,16 +6135,10 @@ export class TeamProvisioningService {
teamName: run.teamName, teamName: run.teamName,
reason: 'auto_allow_category', reason: 'auto_allow_category',
} as ToolApprovalAutoResolved); } as ToolApprovalAutoResolved);
} else if ( } else if (settings.timeoutAction !== 'wait' && !this.pendingTimeouts.has(requestId)) {
this.toolApprovalSettings.timeoutAction !== 'wait' &&
!this.pendingTimeouts.has(requestId)
) {
// Settings changed from 'wait' to allow/deny — start timer for already pending items // Settings changed from 'wait' to allow/deny — start timer for already pending items
this.startApprovalTimeout(run, requestId); this.startApprovalTimeout(run, requestId);
} else if ( } else if (settings.timeoutAction === 'wait' && this.pendingTimeouts.has(requestId)) {
this.toolApprovalSettings.timeoutAction === 'wait' &&
this.pendingTimeouts.has(requestId)
) {
// Settings changed TO 'wait' — clear existing timers // Settings changed TO 'wait' — clear existing timers
this.clearApprovalTimeout(requestId); this.clearApprovalTimeout(requestId);
} }

View file

@ -1145,8 +1145,8 @@ const electronAPI: ElectronAPI = {
); );
}; };
}, },
updateToolApprovalSettings: async (settings: ToolApprovalSettings) => { updateToolApprovalSettings: async (teamName: string, settings: ToolApprovalSettings) => {
return invokeIpcWithResult<void>(TEAM_TOOL_APPROVAL_SETTINGS, settings); return invokeIpcWithResult<void>(TEAM_TOOL_APPROVAL_SETTINGS, teamName, settings);
}, },
readFileForToolApproval: async (filePath: string) => { readFileForToolApproval: async (filePath: string) => {
return invokeIpcWithResult<ToolApprovalFileContent>(TEAM_TOOL_APPROVAL_READ_FILE, filePath); return invokeIpcWithResult<ToolApprovalFileContent>(TEAM_TOOL_APPROVAL_READ_FILE, filePath);

View file

@ -1064,7 +1064,8 @@ export function initializeNotificationListeners(): () => void {
// Sync saved tool approval settings to main process on startup // Sync saved tool approval settings to main process on startup
const savedSettings = useStore.getState().toolApprovalSettings; const savedSettings = useStore.getState().toolApprovalSettings;
api.teams.updateToolApprovalSettings?.(savedSettings).catch(() => { const activeTeam = useStore.getState().selectedTeamName ?? '__global__';
api.teams.updateToolApprovalSettings?.(activeTeam, savedSettings).catch(() => {
// Silently ignore — settings will use defaults until next update // Silently ignore — settings will use defaults until next update
}); });
} }

View file

@ -767,10 +767,11 @@ function extractBaseModel(raw?: string): string | undefined {
return raw.replace(/\[1m\]$/, '') || undefined; return raw.replace(/\[1m\]$/, '') || undefined;
} }
function loadToolApprovalSettings(): ToolApprovalSettings { const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:';
function parseToolApprovalSettings(raw: string | null): ToolApprovalSettings {
if (!raw) return DEFAULT_TOOL_APPROVAL_SETTINGS;
try { try {
const raw = localStorage.getItem('team:toolApprovalSettings');
if (!raw) return DEFAULT_TOOL_APPROVAL_SETTINGS;
const parsed = JSON.parse(raw) as Record<string, unknown>; const parsed = JSON.parse(raw) as Record<string, unknown>;
const d = DEFAULT_TOOL_APPROVAL_SETTINGS; const d = DEFAULT_TOOL_APPROVAL_SETTINGS;
return { return {
@ -801,6 +802,23 @@ function loadToolApprovalSettings(): ToolApprovalSettings {
} }
} }
function loadToolApprovalSettingsForTeam(teamName: string): ToolApprovalSettings {
return parseToolApprovalSettings(localStorage.getItem(TOOL_APPROVAL_PREFIX + teamName));
}
function saveToolApprovalSettingsForTeam(teamName: string, settings: ToolApprovalSettings): void {
try {
localStorage.setItem(TOOL_APPROVAL_PREFIX + teamName, JSON.stringify(settings));
} catch {
// best-effort
}
}
/** Load global settings (legacy fallback for first load / no team selected). */
function loadToolApprovalSettings(): ToolApprovalSettings {
return parseToolApprovalSettings(localStorage.getItem('team:toolApprovalSettings'));
}
export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set, get) => ({ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set, get) => ({
teams: [], teams: [],
teamByName: {}, teamByName: {},
@ -1276,6 +1294,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
selectedTeamLoadNonce: requestNonce, selectedTeamLoadNonce: requestNonce,
selectedTeamError: null, selectedTeamError: null,
reviewActionError: null, reviewActionError: null,
// Load per-team tool approval settings
toolApprovalSettings: loadToolApprovalSettingsForTeam(teamName),
}); });
try { try {
@ -1848,11 +1868,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}, },
}, },
})); }));
// When launching WITHOUT auto-approve, reset the global autoAllowAll flag // Initialize per-team tool approval settings based on skipPermissions flag
// so the user sees the ToolApprovalSheet for this team's tool requests. const initialSettings: ToolApprovalSettings =
if (request.skipPermissions === false && get().toolApprovalSettings.autoAllowAll) { request.skipPermissions === false
await get().updateToolApprovalSettings({ autoAllowAll: false }); ? DEFAULT_TOOL_APPROVAL_SETTINGS
} : { ...DEFAULT_TOOL_APPROVAL_SETTINGS, autoAllowAll: true };
saveToolApprovalSettingsForTeam(request.teamName, initialSettings);
set({ toolApprovalSettings: initialSettings });
try { try {
if (typeof api.teams.createTeam !== 'function') { if (typeof api.teams.createTeam !== 'function') {
throw new Error( throw new Error(
@ -2025,9 +2047,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
[request.teamName]: pendingRunId, [request.teamName]: pendingRunId,
}, },
})); }));
// When launching WITHOUT auto-approve, reset the global autoAllowAll flag // Initialize per-team tool approval settings based on skipPermissions flag
if (request.skipPermissions === false && get().toolApprovalSettings.autoAllowAll) { {
await get().updateToolApprovalSettings({ autoAllowAll: false }); const launchSettings: ToolApprovalSettings =
request.skipPermissions === false
? DEFAULT_TOOL_APPROVAL_SETTINGS
: { ...DEFAULT_TOOL_APPROVAL_SETTINGS, autoAllowAll: true };
saveToolApprovalSettingsForTeam(request.teamName, launchSettings);
set({ toolApprovalSettings: launchSettings });
} }
try { try {
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request)); const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
@ -2321,12 +2348,18 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}, },
updateToolApprovalSettings: async (patch) => { updateToolApprovalSettings: async (patch) => {
const teamName = get().selectedTeamName;
const current = get().toolApprovalSettings; const current = get().toolApprovalSettings;
const merged = { ...current, ...patch }; const merged = { ...current, ...patch };
set({ toolApprovalSettings: merged }); set({ toolApprovalSettings: merged });
localStorage.setItem('team:toolApprovalSettings', JSON.stringify(merged)); // Save per-team if a team is selected, otherwise global fallback
if (teamName) {
saveToolApprovalSettingsForTeam(teamName, merged);
} else {
localStorage.setItem('team:toolApprovalSettings', JSON.stringify(merged));
}
try { try {
await api.teams.updateToolApprovalSettings(merged); await api.teams.updateToolApprovalSettings(teamName ?? '__global__', merged);
} catch (err) { } catch (err) {
logger.warn('Failed to sync tool approval settings to main:', err); logger.warn('Failed to sync tool approval settings to main:', err);
} }

View file

@ -548,7 +548,7 @@ export interface TeamsAPI {
) => Promise<void>; ) => Promise<void>;
validateCliArgs: (rawArgs: string) => Promise<CliArgsValidationResult>; validateCliArgs: (rawArgs: string) => Promise<CliArgsValidationResult>;
onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void; onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void;
updateToolApprovalSettings: (settings: ToolApprovalSettings) => Promise<void>; updateToolApprovalSettings: (teamName: string, settings: ToolApprovalSettings) => Promise<void>;
readFileForToolApproval: (filePath: string) => Promise<ToolApprovalFileContent>; readFileForToolApproval: (filePath: string) => Promise<ToolApprovalFileContent>;
} }