feat: add tool-specific approval overrides

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-24 20:02:29 +01:00
co-authored by Copilot
parent f896fa6442
commit d5c538eed9
22 changed files with 897 additions and 221 deletions
+69 -2
View File
@@ -30,6 +30,9 @@ import {
enqueuePendingApprovalState,
listPendingApprovals,
normalizeApprovalPolicy,
normalizeSessionApprovalSettings,
pruneApprovalPolicyTools,
pruneSessionApprovalSettings,
resolvePendingApproval,
type ApprovalDecision,
type PendingApprovalMessageRecord,
@@ -45,6 +48,7 @@ import {
} from '@shared/domain/sessionLibrary';
import type { SessionEventRecord } from '@shared/domain/event';
import {
applySessionApprovalSettings,
applyScratchpadSessionConfig,
resolveSessionToolingSelection,
createScratchpadSessionConfig,
@@ -64,6 +68,7 @@ import {
} from '@shared/domain/runTimeline';
import {
createSessionToolingSelection,
listApprovalToolNames,
normalizeTheme,
type AppearanceTheme,
type LspProfileDefinition,
@@ -195,7 +200,10 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const issues = validatePatternDefinition(pattern).filter((issue) => issue.level === 'error');
const issues = validatePatternDefinition(
pattern,
this.listKnownApprovalToolNames(workspace),
).filter((issue) => issue.level === 'error');
if (issues.length > 0) {
throw new Error(issues[0].message);
}
@@ -273,6 +281,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
workspace.settings.tooling.mcpServers.push(candidate);
}
this.pruneUnavailableApprovalTools(workspace);
return this.persistAndBroadcast(workspace);
}
@@ -290,6 +299,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
};
}
this.pruneUnavailableApprovalTools(workspace);
return this.persistAndBroadcast(workspace);
}
@@ -318,6 +328,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
workspace.settings.tooling.lspProfiles.push(candidate);
}
this.pruneUnavailableApprovalTools(workspace);
return this.persistAndBroadcast(workspace);
}
@@ -335,6 +346,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
};
}
this.pruneUnavailableApprovalTools(workspace);
return this.persistAndBroadcast(workspace);
}
@@ -667,6 +679,41 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async updateSessionApprovalSettings(
sessionId: string,
autoApprovedToolNames?: string[],
): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
const project = this.requireProject(workspace, session.projectId);
if (session.status === 'running') {
throw new Error('Wait for the current response to finish before changing session approval settings.');
}
const settings = normalizeSessionApprovalSettings(
autoApprovedToolNames === undefined ? undefined : { autoApprovedToolNames },
);
if (
isScratchpadProject(project)
&& settings
&& settings.autoApprovedToolNames.length > 0
) {
throw new Error('Scratchpad sessions do not support tool auto-approval settings.');
}
const knownToolNames = new Set(this.listKnownApprovalToolNames(workspace));
const unknownToolName = settings?.autoApprovedToolNames.find((toolName) => !knownToolNames.has(toolName));
if (unknownToolName) {
throw new Error(`Unknown approval tool "${unknownToolName}".`);
}
session.approvalSettings = settings;
session.updatedAt = nowIso();
return this.persistAndBroadcast(workspace);
}
async querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]> {
const workspace = await this.loadWorkspace();
return queryWorkspaceSessions(workspace, input);
@@ -1119,9 +1166,29 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
const patternWithSessionConfig = isScratchpadProject(project)
? applyScratchpadSessionConfig(pattern, session)
: pattern;
const patternWithApprovalSettings = applySessionApprovalSettings(patternWithSessionConfig, session);
const modelCatalog = await this.loadAvailableModelCatalog();
return normalizePatternModels(patternWithSessionConfig, modelCatalog);
return normalizePatternModels(patternWithApprovalSettings, modelCatalog);
}
private listKnownApprovalToolNames(workspace: WorkspaceState): string[] {
return listApprovalToolNames(workspace.settings.tooling);
}
private pruneUnavailableApprovalTools(workspace: WorkspaceState): void {
const knownToolNames = this.listKnownApprovalToolNames(workspace);
for (const pattern of workspace.patterns) {
pattern.approvalPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, knownToolNames);
}
for (const session of workspace.sessions) {
session.approvalSettings = pruneSessionApprovalSettings(
session.approvalSettings,
knownToolNames,
);
}
}
private buildRunTurnToolingConfig(
+6
View File
@@ -13,6 +13,7 @@ import type {
SetPatternFavoriteInput,
SetSessionArchivedInput,
SetSessionPinnedInput,
UpdateSessionApprovalSettingsInput,
UpdateSessionToolingInput,
UpdateScratchpadSessionConfigInput,
} from '@shared/contracts/ipc';
@@ -60,6 +61,11 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi
input.enabledLspProfileIds,
),
);
ipcMain.handle(
ipcChannels.updateSessionApprovalSettings,
(_event, input: UpdateSessionApprovalSettingsInput) =>
service.updateSessionApprovalSettings(input.sessionId, input.autoApprovedToolNames),
);
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
service.createSession(input.projectId, input.patternId),
);
+19 -3
View File
@@ -4,10 +4,17 @@ import { createBuiltinPatterns } from '@shared/domain/pattern';
import type { PatternDefinition } from '@shared/domain/pattern';
import { mergeScratchpadProject } from '@shared/domain/project';
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
import { normalizeSessionToolingSelection, normalizeWorkspaceSettings } from '@shared/domain/tooling';
import {
listApprovalToolNames,
normalizeSessionToolingSelection,
normalizeWorkspaceSettings,
} from '@shared/domain/tooling';
import {
normalizeApprovalPolicy,
normalizePendingApprovalState,
normalizeSessionApprovalSettings,
pruneApprovalPolicyTools,
pruneSessionApprovalSettings,
} from '@shared/domain/approval';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
import { nowIso } from '@shared/utils/ids';
@@ -60,24 +67,33 @@ export class WorkspaceRepository {
}
const projects = mergeScratchpadProject(stored.projects ?? [], this.scratchpadPath);
const settings = normalizeWorkspaceSettings(stored.settings);
const knownToolNames = listApprovalToolNames(settings.tooling);
const workspace: WorkspaceState = {
...stored,
patterns: mergePatterns(stored.patterns ?? []).map((pattern) => ({
...pattern,
approvalPolicy: normalizeApprovalPolicy(pattern.approvalPolicy),
approvalPolicy: pruneApprovalPolicyTools(
normalizeApprovalPolicy(pattern.approvalPolicy),
knownToolNames,
),
})),
projects,
sessions: (stored.sessions ?? []).map((session) => ({
...session,
runs: normalizeSessionRunRecords(session.runs),
tooling: normalizeSessionToolingSelection(session.tooling),
approvalSettings: pruneSessionApprovalSettings(
normalizeSessionApprovalSettings(session.approvalSettings),
knownToolNames,
),
...normalizePendingApprovalState({
pendingApproval: session.pendingApproval,
pendingApprovalQueue: session.pendingApprovalQueue,
}),
})),
settings: normalizeWorkspaceSettings(stored.settings),
settings,
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
? stored.selectedProjectId
: projects[0]?.id,