fix: use runtime tools for approval auto-approval

- load runtime tools dynamically from Copilot CLI capabilities via tools.list
- merge runtime tools with configured MCP and LSP tools in the approval catalog
- keep a fallback builtin runtime tool list when capabilities are unavailable
- move approval-tool pruning to the app service so dynamic tools are not dropped on load
- update approval UI and docs to use the corrected runtime-tool model

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-24 20:16:11 +01:00
co-authored by Copilot
parent 4b7409368f
commit 130b114906
16 changed files with 255 additions and 87 deletions
+2 -1
View File
@@ -246,8 +246,9 @@ This is a good example of a cross-cutting concern that spans multiple layers wit
Tooling is deliberately split into two levels: Tooling is deliberately split into two levels:
- **dynamic runtime tools** reported by the Copilot CLI, with a fallback catalog for startup/offline cases
- **global definitions** for MCP servers and LSP profiles - **global definitions** for MCP servers and LSP profiles
- **pattern defaults** for which known tools can bypass manual approval - **pattern defaults** for which known runtime tools can bypass manual approval
- **per-session overrides** for both tool enablement and tool auto-approval - **per-session overrides** for both tool enablement and tool auto-approval
This lets the application treat tooling as reusable workspace capability while still preserving session-level control and safety. This lets the application treat tooling as reusable workspace capability while still preserving session-level control and safety.
+39 -45
View File
@@ -8,7 +8,7 @@ This builds on the existing approval-checkpoint system:
- `final-response` approvals still work the same way - `final-response` approvals still work the same way
- `tool-call` checkpoints still decide **whether a tool call needs approval at all** - `tool-call` checkpoints still decide **whether a tool call needs approval at all**
- the new feature decides **which known tools can be auto-approved instead of surfacing a manual approval** - the new feature decides **which known runtime tools can be auto-approved instead of surfacing a manual approval**
The approval queue work from earlier is unchanged and still applies. The approval queue work from earlier is unchanged and still applies.
@@ -80,21 +80,39 @@ Unknown or non-tool-specific permission requests still require manual approval w
Use the shared helper in `src/shared/domain/tooling.ts`: Use the shared helper in `src/shared/domain/tooling.ts`:
```ts ```ts
listApprovalToolDefinitions(workspace.settings.tooling) listApprovalToolDefinitions(workspace.settings.tooling, sidecarCapabilities?.runtimeTools)
``` ```
This is the canonical source for UI rendering and validation. This is the canonical source for UI rendering and validation.
Important:
- runtime tools should come from `describeSidecarCapabilities().runtimeTools` when available
- the shared helper falls back to a conservative built-in catalog until capabilities load or when the CLI cannot report tools
- the helper also merges configured MCP server tools and derived LSP tools
Each returned item includes: Each returned item includes:
- `id` → the runtime tool identifier used by approvals - `id` → the runtime tool identifier used by approvals
- `label` → human-readable label for the UI - `label` → human-readable label for the UI
- `kind``mcp`, `lsp`, or `mixed` - `kind``builtin`, `mcp`, `lsp`, or `mixed`
- `providerIds` - `providerIds`
- `providerNames` - `providerNames`
Do **not** re-derive tool IDs in the renderer. Do **not** re-derive tool IDs in the renderer.
### Runtime tool IDs
For Copilot CLI runtime tools, the ID is the tool name returned by the sidecar capability payload.
Examples:
- `web_fetch`
- `view`
- `glob`
Descriptions come from the sidecar when available and can be surfaced in the UI.
### MCP tool IDs ### MCP tool IDs
For MCP tools, the runtime ID is the raw tool name from `server.tools`. For MCP tools, the runtime ID is the raw tool name from `server.tools`.
@@ -157,7 +175,7 @@ The backend now enforces and maintains tool references consistently:
- saving a pattern rejects unknown `autoApprovedToolNames` - saving a pattern rejects unknown `autoApprovedToolNames`
- updating session approval settings rejects unknown tool IDs - updating session approval settings rejects unknown tool IDs
- scratchpad sessions reject non-empty session tool auto-approval overrides - scratchpad sessions reject non-empty session tool auto-approval overrides
- workspace load prunes stale tool IDs from patterns and sessions - workspace load normalizes approval state and the app service prunes stale tool IDs after runtime tools/capabilities are available
- saving or deleting MCP/LSP definitions also prunes stale tool IDs from patterns and sessions - saving or deleting MCP/LSP definitions also prunes stale tool IDs from patterns and sessions
This is intentional: stored approval defaults should only reference tools that still exist. This is intentional: stored approval defaults should only reference tools that still exist.
@@ -180,13 +198,17 @@ Existing UI can keep working without changes, but the UX agent can now surface b
- `src/shared/domain/approval.ts` - `src/shared/domain/approval.ts`
- pattern defaults, session override model, effective-policy helpers, pruning/validation helpers - pattern defaults, session override model, effective-policy helpers, pruning/validation helpers
- `src/shared/domain/tooling.ts` - `src/shared/domain/tooling.ts`
- canonical `listApprovalToolDefinitions(...)` helper - canonical merged runtime/MCP/LSP `listApprovalToolDefinitions(...)` helper
- `src/shared/contracts/sidecar.ts`
- runtime tool capability payload
- `src/shared/domain/session.ts` - `src/shared/domain/session.ts`
- session approval settings + effective pattern merge helper - session approval settings + effective pattern merge helper
- `src/main/EryxAppService.ts` - `src/main/EryxAppService.ts`
- pattern validation, session override IPC handler, tooling cleanup, effective-pattern merge - pattern validation, session override IPC handler, tooling cleanup, effective-pattern merge
- `src/main/persistence/workspaceRepository.ts` - `src/main/persistence/workspaceRepository.ts`
- load-time normalization + pruning for stale tool IDs - load-time normalization without prematurely pruning dynamic runtime tool IDs
- `sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs`
- dynamic Copilot CLI runtime tool discovery via `tools.list`
- `src/shared/contracts/ipc.ts` - `src/shared/contracts/ipc.ts`
- `src/shared/contracts/channels.ts` - `src/shared/contracts/channels.ts`
- `src\preload\index.ts` - `src\preload\index.ts`
@@ -198,55 +220,27 @@ Existing UI can keep working without changes, but the UX agent can now surface b
## UX work for the frontend agent ## UX work for the frontend agent
### 1. Pattern editor: default tool auto-approval All UX tasks from this section have been implemented in commit `4ff5cbb`.
In `PatternEditor`, add a section that renders `listApprovalToolDefinitions(workspace.settings.tooling)`. ### 1. Pattern editor: default tool auto-approval — ✅ Done
Recommended behavior: `PatternEditor` renders `listApprovalToolDefinitions(workspace.settings.tooling, sidecarCapabilities?.runtimeTools)` with toggles writing to `pattern.approvalPolicy.autoApprovedToolNames`. Runtime tools come from the sidecar when available and fall back to the shared built-in catalog until capabilities load.
- show every available approval tool with a toggle ### 2. Activity panel: per-session override — ✅ Done
- write the selected tool IDs to `pattern.approvalPolicy.autoApprovedToolNames`
- keep this separate from the existing `tool-call` / `final-response` checkpoint toggles
- if there are no tools, show an empty state instead of an interactive list
### 2. Activity panel: per-session override Right-side Activity panel shows "Inheriting pattern defaults" / "Custom for this session" badge with "Reset to pattern" action. Toggle rows for each tool call `updateSessionApprovalSettings(...)`.
In the right-side Activity panel, add a per-session auto-approval section for tools. ### 3. Disable while running — ✅ Done
Recommended behavior: Per-session override controls disabled when `session.status === 'running'`. Scratchpad shows non-interactive explanation.
- base the list on `listApprovalToolDefinitions(workspace.settings.tooling)` ### 4. Surface tool context in approval UI — ✅ Done
- show the current **effective** state
- distinguish inherited vs overridden state in the UI
- allow resetting the session to inherit the pattern defaults
- call `updateSessionApprovalSettings(...)` when the user changes the override
Suggested UX: `approval.toolName` displayed in both the active approval banner and queued approval list when present.
- `Inheriting pattern defaults` badge/state when `session.approvalSettings` is `undefined` ### 5. Avoid ID duplication logic in the renderer — ✅ Done
- `Custom for this session` badge/state when the override object exists
- `Reset to pattern` action that calls `updateSessionApprovalSettings({ sessionId })`
### 3. Disable while running All tool ID resolution uses `listApprovalToolDefinitions()` output exclusively.
Match the current tools behavior:
- disable the per-session override controls while `session.status === 'running'`
- scratchpad sessions should show a non-interactive explanation because tool auto-approval does not apply there
### 4. Surface tool context in approval UI
Optional but recommended:
- show `approval.toolName` in the active approval banner / queued approval list when present
- this is now available for tool-specific approval requests
### 5. Avoid ID duplication logic in the renderer
Important:
- do not manually rebuild LSP tool IDs in UI code
- use the shared helper output and persist the `id` values from it
## Validation commands ## Validation commands
+1 -1
View File
@@ -84,7 +84,7 @@ Eryx includes connection status in the app so you can quickly tell whether Copil
Use a simple single-agent setup to begin, or choose a saved multi-agent pattern when you want a more structured workflow. Use a simple single-agent setup to begin, or choose a saved multi-agent pattern when you want a more structured workflow.
5. **Configure optional tooling** 5. **Configure optional tooling**
If you want MCP or LSP support, add the global definitions in settings and then enable the ones you want for the current session from the Activity panel. You can also set pattern-level tool auto-approval defaults and override them per session. If you want MCP or LSP support, add the global definitions in settings and then enable the ones you want for the current session from the Activity panel. Eryx also surfaces Copilot CLI runtime tools for tool auto-approval, and you can set pattern-level defaults and override them per session.
6. **Start working** 6. **Start working**
Ask a question, describe a task, or explore a project. As the run progresses, you can watch the participating agents and keep the session for later. Ask a question, describe a task, or explore a project. As the run progresses, you can watch the participating agents and keep the session for later.
@@ -69,6 +69,13 @@ public sealed class SidecarModelCapabilityDto
public string? DefaultReasoningEffort { get; init; } public string? DefaultReasoningEffort { get; init; }
} }
public sealed class SidecarRuntimeToolDto
{
public string Id { get; init; } = string.Empty;
public string Label { get; init; } = string.Empty;
public string? Description { get; init; }
}
public sealed class SidecarConnectionDiagnosticsDto public sealed class SidecarConnectionDiagnosticsDto
{ {
public string Status { get; init; } = "copilot-error"; public string Status { get; init; } = "copilot-error";
@@ -103,6 +110,7 @@ public sealed class SidecarCapabilitiesDto
public string Runtime { get; init; } = "dotnet-maf"; public string Runtime { get; init; } = "dotnet-maf";
public Dictionary<string, SidecarModeCapabilityDto> Modes { get; init; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary<string, SidecarModeCapabilityDto> Modes { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<SidecarModelCapabilityDto> Models { get; init; } = []; public IReadOnlyList<SidecarModelCapabilityDto> Models { get; init; } = [];
public IReadOnlyList<SidecarRuntimeToolDto> RuntimeTools { get; init; } = [];
public SidecarConnectionDiagnosticsDto Connection { get; init; } = new(); public SidecarConnectionDiagnosticsDto Connection { get; init; } = new();
} }
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using GitHub.Copilot.SDK; using GitHub.Copilot.SDK;
using GitHub.Copilot.SDK.Rpc;
using Eryx.AgentHost.Contracts; using Eryx.AgentHost.Contracts;
namespace Eryx.AgentHost.Services; namespace Eryx.AgentHost.Services;
@@ -187,6 +188,7 @@ public sealed class SidecarProtocolHost
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken) private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
{ {
IReadOnlyList<SidecarModelCapabilityDto> models = []; IReadOnlyList<SidecarModelCapabilityDto> models = [];
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = [];
CopilotCliContext cliContext; CopilotCliContext cliContext;
SidecarConnectionDiagnosticsDto connection; SidecarConnectionDiagnosticsDto connection;
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null; SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
@@ -227,6 +229,14 @@ public sealed class SidecarProtocolHost
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false); models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false);
try
{
runtimeTools = await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[eryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}");
}
cliVersion = await cliVersionTask.ConfigureAwait(false); cliVersion = await cliVersionTask.ConfigureAwait(false);
connection = CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account); connection = CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account);
} }
@@ -241,6 +251,7 @@ public sealed class SidecarProtocolHost
{ {
Modes = BuildModeCapabilities(), Modes = BuildModeCapabilities(),
Models = models, Models = models,
RuntimeTools = runtimeTools,
Connection = connection, Connection = connection,
}; };
} }
@@ -284,6 +295,24 @@ public sealed class SidecarProtocolHost
.ToList(); .ToList();
} }
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> ListAvailableRuntimeToolsAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false);
return result.Tools
.Where(tool => !string.IsNullOrWhiteSpace(tool.Name))
.Select(tool => new SidecarRuntimeToolDto
{
Id = tool.Name.Trim(),
Label = tool.Name.Trim(),
Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description.Trim(),
})
.DistinctBy(tool => tool.Id, StringComparer.OrdinalIgnoreCase)
.OrderBy(tool => tool.Label, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static bool IsReasoningEffort(string? value) private static bool IsReasoningEffort(string? value)
{ {
return value is "low" or "medium" or "high" or "xhigh"; return value is "low" or "medium" or "high" or "xhigh";
@@ -37,6 +37,10 @@ public sealed class SidecarProtocolHostTests
JsonElement model = Assert.Single(models); JsonElement model = Assert.Single(models);
Assert.Equal("gpt-5.4", model.GetProperty("id").GetString()); Assert.Equal("gpt-5.4", model.GetProperty("id").GetString());
Assert.Equal("medium", model.GetProperty("defaultReasoningEffort").GetString()); Assert.Equal("medium", model.GetProperty("defaultReasoningEffort").GetString());
JsonElement[] runtimeTools = capabilities.GetProperty("runtimeTools").EnumerateArray().ToArray();
JsonElement runtimeTool = Assert.Single(runtimeTools);
Assert.Equal("web_fetch", runtimeTool.GetProperty("id").GetString());
Assert.Equal("web_fetch", runtimeTool.GetProperty("label").GetString());
JsonElement connection = capabilities.GetProperty("connection"); JsonElement connection = capabilities.GetProperty("connection");
Assert.Equal("ready", connection.GetProperty("status").GetString()); Assert.Equal("ready", connection.GetProperty("status").GetString());
Assert.Equal(@"C:\tools\copilot\copilot.exe", connection.GetProperty("copilotCliPath").GetString()); Assert.Equal(@"C:\tools\copilot\copilot.exe", connection.GetProperty("copilotCliPath").GetString());
@@ -403,6 +407,15 @@ public sealed class SidecarProtocolHostTests
DefaultReasoningEffort = "medium", DefaultReasoningEffort = "medium",
}, },
], ],
RuntimeTools =
[
new SidecarRuntimeToolDto
{
Id = "web_fetch",
Label = "web_fetch",
Description = "Fetch content from the web.",
},
],
Connection = new SidecarConnectionDiagnosticsDto Connection = new SidecarConnectionDiagnosticsDto
{ {
Status = "ready", Status = "ready",
+44 -13
View File
@@ -103,6 +103,16 @@ function isBuiltinPattern(patternId: string): boolean {
return patternId.startsWith('pattern-'); return patternId.startsWith('pattern-');
} }
function equalStringArrays(left?: readonly string[], right?: readonly string[]): boolean {
const normalizedLeft = left ?? [];
const normalizedRight = right ?? [];
if (normalizedLeft.length !== normalizedRight.length) {
return false;
}
return normalizedLeft.every((value, index) => value === normalizedRight[index]);
}
export class EryxAppService extends EventEmitter<AppServiceEvents> { export class EryxAppService extends EventEmitter<AppServiceEvents> {
private readonly workspaceRepository = new WorkspaceRepository(); private readonly workspaceRepository = new WorkspaceRepository();
private readonly sidecar = new SidecarClient(); private readonly sidecar = new SidecarClient();
@@ -124,7 +134,8 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
async loadWorkspace(): Promise<WorkspaceState> { async loadWorkspace(): Promise<WorkspaceState> {
if (!this.workspace) { if (!this.workspace) {
this.workspace = await this.workspaceRepository.load(); this.workspace = await this.workspaceRepository.load();
if (this.failInterruptedPendingApprovals(this.workspace)) { const didPruneApprovalTools = await this.pruneUnavailableApprovalTools(this.workspace);
if (didPruneApprovalTools || this.failInterruptedPendingApprovals(this.workspace)) {
await this.workspaceRepository.save(this.workspace); await this.workspaceRepository.save(this.workspace);
} }
} }
@@ -200,9 +211,10 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> { async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace(); const workspace = await this.loadWorkspace();
const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace);
const issues = validatePatternDefinition( const issues = validatePatternDefinition(
pattern, pattern,
this.listKnownApprovalToolNames(workspace), knownApprovalToolNames,
).filter((issue) => issue.level === 'error'); ).filter((issue) => issue.level === 'error');
if (issues.length > 0) { if (issues.length > 0) {
throw new Error(issues[0].message); throw new Error(issues[0].message);
@@ -281,7 +293,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
workspace.settings.tooling.mcpServers.push(candidate); workspace.settings.tooling.mcpServers.push(candidate);
} }
this.pruneUnavailableApprovalTools(workspace); await this.pruneUnavailableApprovalTools(workspace);
return this.persistAndBroadcast(workspace); return this.persistAndBroadcast(workspace);
} }
@@ -299,7 +311,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
}; };
} }
this.pruneUnavailableApprovalTools(workspace); await this.pruneUnavailableApprovalTools(workspace);
return this.persistAndBroadcast(workspace); return this.persistAndBroadcast(workspace);
} }
@@ -328,7 +340,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
workspace.settings.tooling.lspProfiles.push(candidate); workspace.settings.tooling.lspProfiles.push(candidate);
} }
this.pruneUnavailableApprovalTools(workspace); await this.pruneUnavailableApprovalTools(workspace);
return this.persistAndBroadcast(workspace); return this.persistAndBroadcast(workspace);
} }
@@ -346,7 +358,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
}; };
} }
this.pruneUnavailableApprovalTools(workspace); await this.pruneUnavailableApprovalTools(workspace);
return this.persistAndBroadcast(workspace); return this.persistAndBroadcast(workspace);
} }
@@ -703,7 +715,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
throw new Error('Scratchpad sessions do not support tool auto-approval settings.'); throw new Error('Scratchpad sessions do not support tool auto-approval settings.');
} }
const knownToolNames = new Set(this.listKnownApprovalToolNames(workspace)); const knownToolNames = new Set(await this.listKnownApprovalToolNames(workspace));
const unknownToolName = settings?.autoApprovedToolNames.find((toolName) => !knownToolNames.has(toolName)); const unknownToolName = settings?.autoApprovedToolNames.find((toolName) => !knownToolNames.has(toolName));
if (unknownToolName) { if (unknownToolName) {
throw new Error(`Unknown approval tool "${unknownToolName}".`); throw new Error(`Unknown approval tool "${unknownToolName}".`);
@@ -1172,23 +1184,42 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
return normalizePatternModels(patternWithApprovalSettings, modelCatalog); return normalizePatternModels(patternWithApprovalSettings, modelCatalog);
} }
private listKnownApprovalToolNames(workspace: WorkspaceState): string[] { private async listKnownApprovalToolNames(workspace: WorkspaceState): Promise<string[]> {
return listApprovalToolNames(workspace.settings.tooling); const capabilities = await this.loadSidecarCapabilities();
const runtimeTools = capabilities.runtimeTools.length > 0 ? capabilities.runtimeTools : undefined;
return listApprovalToolNames(workspace.settings.tooling, runtimeTools);
} }
private pruneUnavailableApprovalTools(workspace: WorkspaceState): void { private async pruneUnavailableApprovalTools(workspace: WorkspaceState): Promise<boolean> {
const knownToolNames = this.listKnownApprovalToolNames(workspace); const knownToolNames = await this.listKnownApprovalToolNames(workspace);
let changed = false;
for (const pattern of workspace.patterns) { for (const pattern of workspace.patterns) {
pattern.approvalPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, knownToolNames); const nextPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, knownToolNames);
if (!equalStringArrays(
pattern.approvalPolicy?.autoApprovedToolNames,
nextPolicy?.autoApprovedToolNames,
)) {
pattern.approvalPolicy = nextPolicy;
changed = true;
}
} }
for (const session of workspace.sessions) { for (const session of workspace.sessions) {
session.approvalSettings = pruneSessionApprovalSettings( const nextSettings = pruneSessionApprovalSettings(
session.approvalSettings, session.approvalSettings,
knownToolNames, knownToolNames,
); );
if (!equalStringArrays(
session.approvalSettings?.autoApprovedToolNames,
nextSettings?.autoApprovedToolNames,
)) {
session.approvalSettings = nextSettings;
changed = true;
}
} }
return changed;
} }
private buildRunTurnToolingConfig( private buildRunTurnToolingConfig(
+2 -12
View File
@@ -5,7 +5,6 @@ import type { PatternDefinition } from '@shared/domain/pattern';
import { mergeScratchpadProject } from '@shared/domain/project'; import { mergeScratchpadProject } from '@shared/domain/project';
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline'; import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
import { import {
listApprovalToolNames,
normalizeSessionToolingSelection, normalizeSessionToolingSelection,
normalizeWorkspaceSettings, normalizeWorkspaceSettings,
} from '@shared/domain/tooling'; } from '@shared/domain/tooling';
@@ -13,8 +12,6 @@ import {
normalizeApprovalPolicy, normalizeApprovalPolicy,
normalizePendingApprovalState, normalizePendingApprovalState,
normalizeSessionApprovalSettings, normalizeSessionApprovalSettings,
pruneApprovalPolicyTools,
pruneSessionApprovalSettings,
} from '@shared/domain/approval'; } from '@shared/domain/approval';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
import { nowIso } from '@shared/utils/ids'; import { nowIso } from '@shared/utils/ids';
@@ -68,26 +65,19 @@ export class WorkspaceRepository {
const projects = mergeScratchpadProject(stored.projects ?? [], this.scratchpadPath); const projects = mergeScratchpadProject(stored.projects ?? [], this.scratchpadPath);
const settings = normalizeWorkspaceSettings(stored.settings); const settings = normalizeWorkspaceSettings(stored.settings);
const knownToolNames = listApprovalToolNames(settings.tooling);
const workspace: WorkspaceState = { const workspace: WorkspaceState = {
...stored, ...stored,
patterns: mergePatterns(stored.patterns ?? []).map((pattern) => ({ patterns: mergePatterns(stored.patterns ?? []).map((pattern) => ({
...pattern, ...pattern,
approvalPolicy: pruneApprovalPolicyTools( approvalPolicy: normalizeApprovalPolicy(pattern.approvalPolicy),
normalizeApprovalPolicy(pattern.approvalPolicy),
knownToolNames,
),
})), })),
projects, projects,
sessions: (stored.sessions ?? []).map((session) => ({ sessions: (stored.sessions ?? []).map((session) => ({
...session, ...session,
runs: normalizeSessionRunRecords(session.runs), runs: normalizeSessionRunRecords(session.runs),
tooling: normalizeSessionToolingSelection(session.tooling), tooling: normalizeSessionToolingSelection(session.tooling),
approvalSettings: pruneSessionApprovalSettings( approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
normalizeSessionApprovalSettings(session.approvalSettings),
knownToolNames,
),
...normalizePendingApprovalState({ ...normalizePendingApprovalState({
pendingApproval: session.pendingApproval, pendingApproval: session.pendingApproval,
pendingApprovalQueue: session.pendingApprovalQueue, pendingApprovalQueue: session.pendingApprovalQueue,
+1
View File
@@ -275,6 +275,7 @@ export default function App() {
lspProfiles={workspace.settings.tooling.lspProfiles} lspProfiles={workspace.settings.tooling.lspProfiles}
mcpServers={workspace.settings.tooling.mcpServers} mcpServers={workspace.settings.tooling.mcpServers}
toolingSettings={workspace.settings.tooling} toolingSettings={workspace.settings.tooling}
runtimeTools={sidecarCapabilities?.runtimeTools}
onJumpToMessage={jumpToMessage} onJumpToMessage={jumpToMessage}
onUpdateSessionTooling={(selection) => { onUpdateSessionTooling={(selection) => {
void api.updateSessionTooling({ void api.updateSessionTooling({
+20 -3
View File
@@ -19,6 +19,7 @@ import {
import type { import type {
LspProfileDefinition, LspProfileDefinition,
McpServerDefinition, McpServerDefinition,
RuntimeToolDefinition,
SessionToolingSelection, SessionToolingSelection,
WorkspaceToolingSettings, WorkspaceToolingSettings,
} from '@shared/domain/tooling'; } from '@shared/domain/tooling';
@@ -163,6 +164,7 @@ interface ActivityPanelProps {
lspProfiles: LspProfileDefinition[]; lspProfiles: LspProfileDefinition[];
mcpServers: McpServerDefinition[]; mcpServers: McpServerDefinition[];
toolingSettings: WorkspaceToolingSettings; toolingSettings: WorkspaceToolingSettings;
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
onJumpToMessage?: (messageId: string) => void; onJumpToMessage?: (messageId: string) => void;
onUpdateSessionTooling: (selection: SessionToolingSelection) => void; onUpdateSessionTooling: (selection: SessionToolingSelection) => void;
onUpdateSessionApprovalSettings: (settings: { autoApprovedToolNames?: string[] }) => void; onUpdateSessionApprovalSettings: (settings: { autoApprovedToolNames?: string[] }) => void;
@@ -176,6 +178,7 @@ export function ActivityPanel({
lspProfiles, lspProfiles,
mcpServers, mcpServers,
toolingSettings, toolingSettings,
runtimeTools,
onJumpToMessage, onJumpToMessage,
onUpdateSessionTooling, onUpdateSessionTooling,
onUpdateSessionApprovalSettings, onUpdateSessionApprovalSettings,
@@ -188,7 +191,10 @@ export function ActivityPanel({
[activity, pattern.agents], [activity, pattern.agents],
); );
const selection = useMemo(() => resolveSessionToolingSelection(session), [session]); const selection = useMemo(() => resolveSessionToolingSelection(session), [session]);
const approvalTools = useMemo(() => listApprovalToolDefinitions(toolingSettings), [toolingSettings]); const approvalTools = useMemo(
() => listApprovalToolDefinitions(toolingSettings, runtimeTools),
[runtimeTools, toolingSettings],
);
const isOverridden = session.approvalSettings !== undefined; const isOverridden = session.approvalSettings !== undefined;
const effectiveAutoApproved = new Set( const effectiveAutoApproved = new Set(
@@ -353,7 +359,7 @@ export function ActivityPanel({
</p> </p>
) : approvalTools.length === 0 ? ( ) : approvalTools.length === 0 ? (
<p className="text-[11px] leading-relaxed text-zinc-600"> <p className="text-[11px] leading-relaxed text-zinc-600">
Add MCP servers or LSP profiles in Settings to configure tool auto-approvals. No approval-capable runtime tools are currently available.
</p> </p>
) : ( ) : (
<> <>
@@ -478,7 +484,13 @@ function ApprovalOverrideRow({
disabled: boolean; disabled: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
const kindBadge = tool.kind === 'lsp' ? 'LSP' : tool.kind === 'mcp' ? 'MCP' : 'Mixed'; const kindBadge = tool.kind === 'builtin'
? 'Built-in'
: tool.kind === 'lsp'
? 'LSP'
: tool.kind === 'mcp'
? 'MCP'
: 'Mixed';
return ( return (
<button <button
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition ${ className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition ${
@@ -496,6 +508,11 @@ function ApprovalOverrideRow({
{kindBadge} {kindBadge}
</span> </span>
</div> </div>
{(tool.description || tool.providerNames.length > 0) && (
<div className="truncate text-[10px] text-zinc-600">
{tool.description ?? tool.providerNames.join(', ')}
</div>
)}
</div> </div>
<ToggleSwitch enabled={enabled} /> <ToggleSwitch enabled={enabled} />
</button> </button>
+16 -5
View File
@@ -32,6 +32,7 @@ import {
import { import {
listApprovalToolDefinitions, listApprovalToolDefinitions,
type ApprovalToolDefinition, type ApprovalToolDefinition,
type RuntimeToolDefinition,
type WorkspaceToolingSettings, type WorkspaceToolingSettings,
} from '@shared/domain/tooling'; } from '@shared/domain/tooling';
@@ -42,6 +43,7 @@ interface PatternEditorProps {
pattern: PatternDefinition; pattern: PatternDefinition;
isBuiltin: boolean; isBuiltin: boolean;
toolingSettings: WorkspaceToolingSettings; toolingSettings: WorkspaceToolingSettings;
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
onChange: (pattern: PatternDefinition) => void; onChange: (pattern: PatternDefinition) => void;
onDelete?: () => void; onDelete?: () => void;
onSave: () => void; onSave: () => void;
@@ -209,6 +211,7 @@ export function PatternEditor({
pattern, pattern,
isBuiltin, isBuiltin,
toolingSettings, toolingSettings,
runtimeTools,
onChange, onChange,
onDelete, onDelete,
onSave, onSave,
@@ -262,7 +265,7 @@ export function PatternEditor({
}); });
} }
const approvalTools = listApprovalToolDefinitions(toolingSettings); const approvalTools = listApprovalToolDefinitions(toolingSettings, runtimeTools);
const autoApprovedSet = new Set(pattern.approvalPolicy?.autoApprovedToolNames ?? []); const autoApprovedSet = new Set(pattern.approvalPolicy?.autoApprovedToolNames ?? []);
function toggleToolAutoApproval(toolId: string) { function toggleToolAutoApproval(toolId: string) {
@@ -568,7 +571,7 @@ export function PatternEditor({
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 px-4 py-3"> <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 px-4 py-3">
{approvalTools.length === 0 ? ( {approvalTools.length === 0 ? (
<p className="py-2 text-center text-[11px] text-zinc-600"> <p className="py-2 text-center text-[11px] text-zinc-600">
No tools available. Add MCP servers or LSP profiles in Settings to configure auto-approvals. No approval-capable runtime tools are currently available.
</p> </p>
) : ( ) : (
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -719,7 +722,13 @@ function ToolApprovalToggleRow({
enabled: boolean; enabled: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
const kindBadge = tool.kind === 'lsp' ? 'LSP' : tool.kind === 'mcp' ? 'MCP' : 'Mixed'; const kindBadge = tool.kind === 'builtin'
? 'Built-in'
: tool.kind === 'lsp'
? 'LSP'
: tool.kind === 'mcp'
? 'MCP'
: 'Mixed';
return ( return (
<button <button
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition hover:bg-zinc-800/60" className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition hover:bg-zinc-800/60"
@@ -733,8 +742,10 @@ function ToolApprovalToggleRow({
{kindBadge} {kindBadge}
</span> </span>
</div> </div>
{tool.providerNames.length > 0 && ( {(tool.description || tool.providerNames.length > 0) && (
<div className="truncate text-[10px] text-zinc-600">{tool.providerNames.join(', ')}</div> <div className="truncate text-[10px] text-zinc-600">
{tool.description ?? tool.providerNames.join(', ')}
</div>
)} )}
</div> </div>
<ToggleSwitch enabled={enabled} onToggle={onToggle} /> <ToggleSwitch enabled={enabled} onToggle={onToggle} />
@@ -132,6 +132,7 @@ export function SettingsPanel({
setEditingPattern(null); setEditingPattern(null);
}} }}
pattern={editingPattern} pattern={editingPattern}
runtimeTools={sidecarCapabilities?.runtimeTools}
toolingSettings={toolingSettings} toolingSettings={toolingSettings}
/> />
</div> </div>
+2
View File
@@ -1,6 +1,7 @@
import type { PatternDefinition, PatternValidationIssue, ReasoningEffort } from '@shared/domain/pattern'; import type { PatternDefinition, PatternValidationIssue, ReasoningEffort } from '@shared/domain/pattern';
import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/approval'; import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/approval';
import type { ChatMessageRecord } from '@shared/domain/session'; import type { ChatMessageRecord } from '@shared/domain/session';
import type { RuntimeToolDefinition } from '@shared/domain/tooling';
export interface SidecarModeCapability { export interface SidecarModeCapability {
available: boolean; available: boolean;
@@ -52,6 +53,7 @@ export interface SidecarCapabilities {
runtime: 'dotnet-maf'; runtime: 'dotnet-maf';
modes: Record<PatternDefinition['mode'], SidecarModeCapability>; modes: Record<PatternDefinition['mode'], SidecarModeCapability>;
models: SidecarModelCapability[]; models: SidecarModelCapability[];
runtimeTools: RuntimeToolDefinition[];
connection: SidecarConnectionDiagnostics; connection: SidecarConnectionDiagnostics;
} }
+42 -3
View File
@@ -52,7 +52,13 @@ export interface SessionToolingSelection {
enabledLspProfileIds: string[]; enabledLspProfileIds: string[];
} }
export type ApprovalToolKind = 'mcp' | 'lsp' | 'mixed'; export type ApprovalToolKind = 'builtin' | 'mcp' | 'lsp' | 'mixed';
export interface RuntimeToolDefinition {
id: string;
label: string;
description?: string;
}
export interface ApprovalToolDefinition { export interface ApprovalToolDefinition {
id: string; id: string;
@@ -60,6 +66,7 @@ export interface ApprovalToolDefinition {
kind: ApprovalToolKind; kind: ApprovalToolKind;
providerIds: string[]; providerIds: string[];
providerNames: string[]; providerNames: string[];
description?: string;
} }
const lspApprovalOperations = [ const lspApprovalOperations = [
@@ -70,6 +77,17 @@ const lspApprovalOperations = [
{ suffix: 'references', label: 'References' }, { suffix: 'references', label: 'References' },
] as const; ] as const;
// Fallback runtime tools used before sidecar capabilities are loaded or when the
// CLI cannot report its built-in tool catalog dynamically.
const fallbackRuntimeApprovalTools: ReadonlyArray<RuntimeToolDefinition> = [
{ id: 'glob', label: 'glob', description: 'Match files by glob pattern.' },
{ id: 'lsp', label: 'lsp', description: 'Query configured language servers.' },
{ id: 'rg', label: 'rg', description: 'Search file contents with ripgrep.' },
{ id: 'view', label: 'view', description: 'Read files and list directories.' },
{ id: 'web_fetch', label: 'web_fetch', description: 'Fetch content from a URL.' },
{ id: 'web_search', label: 'web_search', description: 'Search the web for current information.' },
];
export function createWorkspaceSettings(): WorkspaceSettings { export function createWorkspaceSettings(): WorkspaceSettings {
return { return {
theme: 'dark', theme: 'dark',
@@ -114,8 +132,21 @@ export function normalizeSessionToolingSelection(
export function listApprovalToolDefinitions( export function listApprovalToolDefinitions(
tooling: WorkspaceToolingSettings, tooling: WorkspaceToolingSettings,
runtimeTools: ReadonlyArray<RuntimeToolDefinition> = fallbackRuntimeApprovalTools,
): ApprovalToolDefinition[] { ): ApprovalToolDefinition[] {
const toolsById = new Map<string, ApprovalToolDefinition>(); const toolsById = new Map<string, ApprovalToolDefinition>();
const runtimeApprovalTools = runtimeTools.length > 0 ? runtimeTools : fallbackRuntimeApprovalTools;
for (const tool of runtimeApprovalTools) {
registerApprovalTool(toolsById, {
id: tool.id,
label: tool.label,
description: tool.description,
kind: 'builtin',
providerId: `builtin:${tool.id}`,
providerName: 'Built-in',
});
}
for (const server of tooling.mcpServers) { for (const server of tooling.mcpServers) {
for (const toolName of normalizeStringArray(server.tools)) { for (const toolName of normalizeStringArray(server.tools)) {
@@ -146,8 +177,11 @@ export function listApprovalToolDefinitions(
left.label.localeCompare(right.label) || left.id.localeCompare(right.id)); left.label.localeCompare(right.label) || left.id.localeCompare(right.id));
} }
export function listApprovalToolNames(tooling: WorkspaceToolingSettings): string[] { export function listApprovalToolNames(
return listApprovalToolDefinitions(tooling).map((tool) => tool.id); tooling: WorkspaceToolingSettings,
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>,
): string[] {
return listApprovalToolDefinitions(tooling, runtimeTools).map((tool) => tool.id);
} }
export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined { export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined {
@@ -276,6 +310,7 @@ function registerApprovalTool(
tool: { tool: {
id: string; id: string;
label: string; label: string;
description?: string;
kind: Exclude<ApprovalToolKind, 'mixed'>; kind: Exclude<ApprovalToolKind, 'mixed'>;
providerId: string; providerId: string;
providerName: string; providerName: string;
@@ -286,6 +321,7 @@ function registerApprovalTool(
toolsById.set(tool.id, { toolsById.set(tool.id, {
id: tool.id, id: tool.id,
label: tool.label, label: tool.label,
description: tool.description,
kind: tool.kind, kind: tool.kind,
providerIds: [tool.providerId], providerIds: [tool.providerId],
providerNames: [tool.providerName], providerNames: [tool.providerName],
@@ -299,6 +335,9 @@ function registerApprovalTool(
if (!existing.providerNames.includes(tool.providerName)) { if (!existing.providerNames.includes(tool.providerName)) {
existing.providerNames.push(tool.providerName); existing.providerNames.push(tool.providerName);
} }
if (!existing.description && tool.description) {
existing.description = tool.description;
}
if (existing.kind !== tool.kind) { if (existing.kind !== tool.kind) {
existing.kind = 'mixed'; existing.kind = 'mixed';
} }
+2 -2
View File
@@ -137,9 +137,9 @@ describe('pattern validation', () => {
...singlePattern!, ...singlePattern!,
approvalPolicy: { approvalPolicy: {
rules: [{ kind: 'tool-call' }], rules: [{ kind: 'tool-call' }],
autoApprovedToolNames: ['git.status', 'unknown.tool'], autoApprovedToolNames: ['web_fetch', 'unknown.tool'],
}, },
}, ['git.status']); }, ['web_fetch']);
expect(issues.find((issue) => issue.field === 'approvalPolicy')?.message).toBe( expect(issues.find((issue) => issue.field === 'approvalPolicy')?.message).toBe(
'Approval auto-approve references unknown tool "unknown.tool".', 'Approval auto-approve references unknown tool "unknown.tool".',
+32 -1
View File
@@ -157,7 +157,7 @@ describe('tooling settings helpers', () => {
).toBe('LSP profile "Typescript LSP" needs the "--stdio" argument.'); ).toBe('LSP profile "Typescript LSP" needs the "--stdio" argument.');
}); });
test('lists approval tools from MCP and LSP definitions using runtime tool identifiers', () => { test('lists builtin, MCP, and LSP approval tools using runtime tool identifiers', () => {
const tools = listApprovalToolDefinitions({ const tools = listApprovalToolDefinitions({
mcpServers: [ mcpServers: [
{ {
@@ -195,6 +195,14 @@ describe('tooling settings helpers', () => {
], ],
}); });
expect(tools).toContainEqual({
id: 'web_fetch',
label: 'web_fetch',
description: 'Fetch content from a URL.',
kind: 'builtin',
providerIds: ['builtin:web_fetch'],
providerNames: ['Built-in'],
});
expect(tools).toContainEqual({ expect(tools).toContainEqual({
id: 'git.status', id: 'git.status',
label: 'git.status', label: 'git.status',
@@ -210,4 +218,27 @@ describe('tooling settings helpers', () => {
providerNames: ['TypeScript'], providerNames: ['TypeScript'],
}); });
}); });
test('prefers dynamically reported runtime tools over the fallback builtin catalog', () => {
const tools = listApprovalToolDefinitions(
{ mcpServers: [], lspProfiles: [] },
[
{
id: 'fetch',
label: 'fetch',
description: 'Dynamic runtime tool from Copilot CLI.',
},
],
);
expect(tools).toContainEqual({
id: 'fetch',
label: 'fetch',
description: 'Dynamic runtime tool from Copilot CLI.',
kind: 'builtin',
providerIds: ['builtin:fetch'],
providerNames: ['Built-in'],
});
expect(tools.some((tool) => tool.id === 'web_fetch')).toBe(false);
});
}); });