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
+119 -1
View File
@@ -9,6 +9,11 @@ export interface ApprovalCheckpointRule {
export interface ApprovalPolicy {
rules: ApprovalCheckpointRule[];
autoApprovedToolNames?: string[];
}
export interface SessionApprovalSettings {
autoApprovedToolNames: string[];
}
export interface PendingApprovalMessageRecord {
@@ -53,6 +58,7 @@ export function normalizeApprovalPolicy(policy?: Partial<ApprovalPolicy>): Appro
const rules = Array.isArray(policy?.rules) ? policy.rules : [];
const selectedAgents = new Map<ApprovalCheckpointKind, Set<string>>();
const appliesToAllAgents = new Set<ApprovalCheckpointKind>();
const autoApprovedToolNames = normalizeStringArray(policy?.autoApprovedToolNames);
for (const rule of rules) {
if (!isApprovalCheckpointKind(rule?.kind)) {
@@ -90,18 +96,39 @@ export function normalizeApprovalPolicy(policy?: Partial<ApprovalPolicy>): Appro
return [{ kind, agentIds }];
});
return normalizedRules.length > 0 ? { rules: normalizedRules } : undefined;
if (normalizedRules.length === 0 && autoApprovedToolNames.length === 0) {
return undefined;
}
return {
rules: normalizedRules,
autoApprovedToolNames: autoApprovedToolNames.length > 0 ? autoApprovedToolNames : undefined,
};
}
export function normalizeSessionApprovalSettings(
settings?: Partial<SessionApprovalSettings>,
): SessionApprovalSettings | undefined {
if (settings == null) {
return undefined;
}
return {
autoApprovedToolNames: normalizeStringArray(settings.autoApprovedToolNames),
};
}
export function validateApprovalPolicy(
policy: ApprovalPolicy | undefined,
knownAgentIds: readonly string[],
knownToolNames?: readonly string[],
): string[] {
if (!policy) {
return [];
}
const knownAgents = new Set(normalizeStringArray(knownAgentIds));
const knownTools = knownToolNames ? new Set(normalizeStringArray(knownToolNames)) : undefined;
const issues: string[] = [];
for (const rule of policy.rules) {
@@ -112,6 +139,14 @@ export function validateApprovalPolicy(
}
}
if (knownTools) {
for (const toolName of policy.autoApprovedToolNames ?? []) {
if (!knownTools.has(toolName)) {
issues.push(`Approval auto-approve references unknown tool "${toolName}".`);
}
}
}
return issues;
}
@@ -137,6 +172,81 @@ export function approvalPolicyRequiresCheckpoint(
return rule.agentIds.includes(normalizedAgentId);
}
export function approvalPolicyAutoApprovesTool(
policy: ApprovalPolicy | undefined,
toolName?: string,
): boolean {
const normalizedToolName = normalizeOptionalString(toolName);
if (!normalizedToolName) {
return false;
}
return policy?.autoApprovedToolNames?.includes(normalizedToolName) ?? false;
}
export function approvalPolicyRequiresToolCallApproval(
policy: ApprovalPolicy | undefined,
agentId?: string,
toolName?: string,
): boolean {
if (!approvalPolicyRequiresCheckpoint(policy, 'tool-call', agentId)) {
return false;
}
return !approvalPolicyAutoApprovesTool(policy, toolName);
}
export function resolveEffectiveApprovalPolicy(
policy: ApprovalPolicy | undefined,
sessionSettings?: Partial<SessionApprovalSettings>,
): ApprovalPolicy | undefined {
if (sessionSettings === undefined) {
return normalizeApprovalPolicy(policy);
}
const normalizedPolicy = normalizeApprovalPolicy(policy);
const normalizedSessionSettings = normalizeSessionApprovalSettings(sessionSettings);
return normalizeApprovalPolicy({
rules: normalizedPolicy?.rules ?? [],
autoApprovedToolNames: normalizedSessionSettings?.autoApprovedToolNames,
});
}
export function pruneApprovalPolicyTools(
policy: ApprovalPolicy | undefined,
knownToolNames: readonly string[],
): ApprovalPolicy | undefined {
const normalizedPolicy = normalizeApprovalPolicy(policy);
if (!normalizedPolicy) {
return undefined;
}
return normalizeApprovalPolicy({
...normalizedPolicy,
autoApprovedToolNames: filterKnownToolNames(
normalizedPolicy.autoApprovedToolNames,
knownToolNames,
),
});
}
export function pruneSessionApprovalSettings(
settings: SessionApprovalSettings | undefined,
knownToolNames: readonly string[],
): SessionApprovalSettings | undefined {
const normalizedSettings = normalizeSessionApprovalSettings(settings);
if (normalizedSettings === undefined) {
return undefined;
}
return {
autoApprovedToolNames: filterKnownToolNames(
normalizedSettings.autoApprovedToolNames,
knownToolNames,
),
};
}
export function normalizePendingApproval(
approval?: Partial<PendingApprovalRecord>,
): PendingApprovalRecord | undefined {
@@ -283,3 +393,11 @@ function normalizeStringArray(values?: ReadonlyArray<string>): string[] {
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
}
function filterKnownToolNames(
toolNames: readonly string[] | undefined,
knownToolNames: readonly string[],
): string[] {
const knownTools = new Set(normalizeStringArray(knownToolNames));
return normalizeStringArray(toolNames).filter((toolName) => knownTools.has(toolName));
}
+5 -1
View File
@@ -258,7 +258,10 @@ export function createBuiltinPatterns(timestamp: string): PatternDefinition[] {
];
}
export function validatePatternDefinition(pattern: PatternDefinition): PatternValidationIssue[] {
export function validatePatternDefinition(
pattern: PatternDefinition,
knownToolNames?: readonly string[],
): PatternValidationIssue[] {
const issues: PatternValidationIssue[] = [];
if (!pattern.name.trim()) {
@@ -324,6 +327,7 @@ export function validatePatternDefinition(pattern: PatternDefinition): PatternVa
for (const message of validateApprovalPolicy(
normalizeApprovalPolicy(pattern.approvalPolicy),
pattern.agents.map((agent) => agent.id),
knownToolNames,
)) {
issues.push({
level: 'error',
+27 -1
View File
@@ -4,7 +4,12 @@ import {
normalizeSessionToolingSelection,
type SessionToolingSelection,
} from '@shared/domain/tooling';
import type { PendingApprovalRecord } from '@shared/domain/approval';
import {
normalizeSessionApprovalSettings,
resolveEffectiveApprovalPolicy,
type PendingApprovalRecord,
type SessionApprovalSettings,
} from '@shared/domain/approval';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
export type ChatRole = 'system' | 'user' | 'assistant';
@@ -40,6 +45,7 @@ export interface SessionRecord {
lastError?: string;
scratchpadConfig?: ScratchpadSessionConfig;
tooling?: SessionToolingSelection;
approvalSettings?: SessionApprovalSettings;
pendingApproval?: PendingApprovalRecord;
pendingApprovalQueue?: PendingApprovalRecord[];
runs: SessionRunRecord[];
@@ -77,6 +83,12 @@ export function resolveSessionToolingSelection(
return normalizeSessionToolingSelection(session.tooling ?? createSessionToolingSelection());
}
export function resolveSessionApprovalSettings(
session: Pick<SessionRecord, 'approvalSettings'>,
): SessionApprovalSettings | undefined {
return normalizeSessionApprovalSettings(session.approvalSettings);
}
export function resolveScratchpadSessionConfig(
session: SessionRecord,
pattern: PatternDefinition,
@@ -115,3 +127,17 @@ export function applyScratchpadSessionConfig(
],
};
}
export function applySessionApprovalSettings(
pattern: PatternDefinition,
session: Pick<SessionRecord, 'approvalSettings'>,
): PatternDefinition {
if (session.approvalSettings === undefined) {
return pattern;
}
return {
...pattern,
approvalPolicy: resolveEffectiveApprovalPolicy(pattern.approvalPolicy, session.approvalSettings),
};
}
+5
View File
@@ -181,6 +181,11 @@ export function duplicateSessionRecord(
enabledLspProfileIds: [...session.tooling.enabledLspProfileIds],
}
: undefined,
approvalSettings: session.approvalSettings
? {
autoApprovedToolNames: [...session.approvalSettings.autoApprovedToolNames],
}
: undefined,
pendingApproval: undefined,
pendingApprovalQueue: undefined,
runs: [],
+108
View File
@@ -52,6 +52,24 @@ export interface SessionToolingSelection {
enabledLspProfileIds: string[];
}
export type ApprovalToolKind = 'mcp' | 'lsp' | 'mixed';
export interface ApprovalToolDefinition {
id: string;
label: string;
kind: ApprovalToolKind;
providerIds: string[];
providerNames: string[];
}
const lspApprovalOperations = [
{ suffix: 'workspace_symbols', label: 'Workspace symbols' },
{ suffix: 'document_symbols', label: 'Document symbols' },
{ suffix: 'definition', label: 'Definition' },
{ suffix: 'hover', label: 'Hover' },
{ suffix: 'references', label: 'References' },
] as const;
export function createWorkspaceSettings(): WorkspaceSettings {
return {
theme: 'dark',
@@ -94,6 +112,44 @@ export function normalizeSessionToolingSelection(
};
}
export function listApprovalToolDefinitions(
tooling: WorkspaceToolingSettings,
): ApprovalToolDefinition[] {
const toolsById = new Map<string, ApprovalToolDefinition>();
for (const server of tooling.mcpServers) {
for (const toolName of normalizeStringArray(server.tools)) {
registerApprovalTool(toolsById, {
id: toolName,
label: toolName,
kind: 'mcp',
providerId: server.id,
providerName: server.name,
});
}
}
for (const profile of tooling.lspProfiles) {
const toolPrefix = buildLspApprovalToolPrefix(profile.id);
for (const operation of lspApprovalOperations) {
registerApprovalTool(toolsById, {
id: `${toolPrefix}_${operation.suffix}`,
label: `${profile.name} · ${operation.label}`,
kind: 'lsp',
providerId: profile.id,
providerName: profile.name,
});
}
}
return [...toolsById.values()].sort((left, right) =>
left.label.localeCompare(right.label) || left.id.localeCompare(right.id));
}
export function listApprovalToolNames(tooling: WorkspaceToolingSettings): string[] {
return listApprovalToolDefinitions(tooling).map((tool) => tool.id);
}
export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined {
if (!server.name.trim()) {
return 'MCP server name is required.';
@@ -196,6 +252,58 @@ function requiresTypeScriptLanguageServerStdio(command: string): boolean {
|| executableName === 'typescript-language-server.exe';
}
function buildLspApprovalToolPrefix(value: string): string {
let prefix = '';
for (const char of value) {
if (/[a-z0-9]/i.test(char)) {
prefix += char.toLowerCase();
continue;
}
if (!prefix || prefix.endsWith('_')) {
continue;
}
prefix += '_';
}
const normalized = prefix.replace(/^_+|_+$/g, '');
return normalized ? `lsp_${normalized}` : 'lsp';
}
function registerApprovalTool(
toolsById: Map<string, ApprovalToolDefinition>,
tool: {
id: string;
label: string;
kind: Exclude<ApprovalToolKind, 'mixed'>;
providerId: string;
providerName: string;
},
): void {
const existing = toolsById.get(tool.id);
if (!existing) {
toolsById.set(tool.id, {
id: tool.id,
label: tool.label,
kind: tool.kind,
providerIds: [tool.providerId],
providerNames: [tool.providerName],
});
return;
}
if (!existing.providerIds.includes(tool.providerId)) {
existing.providerIds.push(tool.providerId);
}
if (!existing.providerNames.includes(tool.providerName)) {
existing.providerNames.push(tool.providerName);
}
if (existing.kind !== tool.kind) {
existing.kind = 'mixed';
}
}
function normalizeStringArray(values?: ReadonlyArray<string>): string[] {
if (!values) {
return [];