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(
_event: IpcMainInvokeEvent,
teamName: unknown,
settings: unknown
): 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) {
return { success: false, error: 'Settings must be an object' };
}
@ -2973,7 +2977,10 @@ async function handleToolApprovalSettings(
}
try {
getTeamProvisioningService().updateToolApprovalSettings(s as unknown as ToolApprovalSettings);
getTeamProvisioningService().updateToolApprovalSettings(
teamName as string,
s as unknown as ToolApprovalSettings
);
} catch (err) {
return {
success: false,

View file

@ -1276,7 +1276,7 @@ export class TeamProvisioningService {
private helpOutputCache: string | null = null;
private helpOutputCacheTime = 0;
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 inFlightResponses = new Set<string>();
private controlApiBaseUrlResolver: (() => Promise<string | null>) | null = null;
@ -2022,8 +2022,12 @@ export class TeamProvisioningService {
this.mainWindowRef = win;
}
updateToolApprovalSettings(settings: ToolApprovalSettings): void {
this.toolApprovalSettings = settings;
private getToolApprovalSettings(teamName: string): ToolApprovalSettings {
return this.toolApprovalSettingsByTeam.get(teamName) ?? DEFAULT_TOOL_APPROVAL_SETTINGS;
}
updateToolApprovalSettings(teamName: string, settings: ToolApprovalSettings): void {
this.toolApprovalSettingsByTeam.set(teamName, settings);
this.reEvaluatePendingApprovals();
}
@ -5779,7 +5783,11 @@ export class TeamProvisioningService {
};
// 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) {
logger.info(`[${run.teamName}] Auto-allowing ${toolName} (${autoResult.reason})`);
this.autoAllowControlRequest(run, requestId);
@ -5832,7 +5840,11 @@ export class TeamProvisioningService {
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) {
logger.info(
`[${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 {
const { timeoutAction, timeoutSeconds } = this.toolApprovalSettings;
const { timeoutAction, timeoutSeconds } = this.getToolApprovalSettings(run.teamName);
if (timeoutAction === 'wait') return;
const timeoutMs = timeoutSeconds * 1000;
@ -6013,7 +6025,7 @@ export class TeamProvisioningService {
if (!this.tryClaimResponse(requestId)) return;
// 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') {
// Settings changed to 'wait' but timer fired before reEvaluatePendingApprovals cleared it
this.inFlightResponses.delete(requestId);
@ -6102,13 +6114,10 @@ export class TeamProvisioningService {
private reEvaluatePendingApprovals(): void {
for (const [, run] of this.runs) {
const settings = this.getToolApprovalSettings(run.teamName);
const toRemove: string[] = [];
for (const [requestId, approval] of run.pendingApprovals) {
const result = shouldAutoAllow(
this.toolApprovalSettings,
approval.toolName,
approval.toolInput
);
const result = shouldAutoAllow(settings, approval.toolName, approval.toolInput);
if (result.autoAllow) {
this.clearApprovalTimeout(requestId);
if (!this.tryClaimResponse(requestId)) continue;
@ -6126,16 +6135,10 @@ export class TeamProvisioningService {
teamName: run.teamName,
reason: 'auto_allow_category',
} as ToolApprovalAutoResolved);
} else if (
this.toolApprovalSettings.timeoutAction !== 'wait' &&
!this.pendingTimeouts.has(requestId)
) {
} else if (settings.timeoutAction !== 'wait' && !this.pendingTimeouts.has(requestId)) {
// Settings changed from 'wait' to allow/deny — start timer for already pending items
this.startApprovalTimeout(run, requestId);
} else if (
this.toolApprovalSettings.timeoutAction === 'wait' &&
this.pendingTimeouts.has(requestId)
) {
} else if (settings.timeoutAction === 'wait' && this.pendingTimeouts.has(requestId)) {
// Settings changed TO 'wait' — clear existing timers
this.clearApprovalTimeout(requestId);
}

View file

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

View file

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

View file

@ -548,7 +548,7 @@ export interface TeamsAPI {
) => Promise<void>;
validateCliArgs: (rawArgs: string) => Promise<CliArgsValidationResult>;
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>;
}