diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 9e17494cb32334..284d8866406ff2 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -1336,6 +1336,11 @@ export interface IAgentToolPendingConfirmationSignal { readonly permissionKind?: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'skill' | 'custom-tool' | 'hook' | 'memory' | 'extension-management' | 'extension-permission-access'; /** Host-only auto-approval path target (not part of the dispatched action). */ readonly permissionPath?: string; + /** + * Host-only flag requiring the client to show a confirmation instead of applying host auto-approval. + * The runtime currently sets it for managed Shell, Read, Edit, and Domain selector asks. + */ + readonly managedApprovalRequired?: boolean; /** * Host-only flag (not part of the dispatched action): the model requested * this shell command run OUTSIDE the sandbox (and the host opted in via diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 6b9b6ec48dd2ab..68da5173bb7633 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -143,6 +143,8 @@ export class AgentSideEffects extends Disposable { /** Maps tool call IDs to the agent that owns them, for routing confirmations. */ private readonly _toolCallAgents = new Map(); + /** Managed confirmations are human-only and must never seed host-side session permissions. */ + private readonly _managedApprovalToolCalls = new Set(); private _lastAgentInfos: readonly AgentInfo[] = []; private readonly _permissionManager: SessionPermissionManager; @@ -1110,34 +1112,44 @@ export class AgentSideEffects extends Disposable { toolInput: e.state.toolInput, requestSandboxBypass: e.requestSandboxBypass, }; - const autoApproval = await this._permissionManager.getAutoApproval(approvalEvent, sessionKey); + const autoApproval = e.managedApprovalRequired + ? undefined + : await this._permissionManager.getAutoApproval(approvalEvent, sessionKey); const part = this._stateManager.getSessionState(sessionKey)?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === e.state.toolCallId); const toolCall = part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined; if (toolCall && toolCall.status !== ToolCallStatus.Streaming && toolCall.status !== ToolCallStatus.Running && toolCall.status !== ToolCallStatus.PendingConfirmation) { - this._toolCallAgents.delete(`${sessionKey}:${e.state.toolCallId}`); + const toolCallKey = `${sessionKey}:${e.state.toolCallId}`; + this._toolCallAgents.delete(toolCallKey); + this._managedApprovalToolCalls.delete(toolCallKey); this._logService.trace(`[AgentSideEffects] Dropping stale tool ready for ${e.state.toolCallId}: status=${toolCall.status}`); return; } const contributor = e.state.contributor ?? toolCall?.contributor; let effective = e; + const toolCallKey = `${sessionKey}:${e.state.toolCallId}`; + if (e.managedApprovalRequired) { + this._managedApprovalToolCalls.add(toolCallKey); + } else { + this._managedApprovalToolCalls.delete(toolCallKey); + } const clientShouldAutoApprove = autoApproval !== undefined && contributor?.kind === ToolCallContributorKind.Client && !!e.state.confirmationTitle; if (clientShouldAutoApprove) { - this._toolCallAgents.set(`${sessionKey}:${e.state.toolCallId}`, agent.id); + this._toolCallAgents.set(toolCallKey, agent.id); effective = { ...e, state: { ...e.state, _meta: { ...toolCall?._meta, ...e.state._meta, ...toToolCallMeta({ autoApproveBySetting: true }) } } }; } else if (autoApproval !== undefined) { - this._toolCallAgents.delete(`${sessionKey}:${e.state.toolCallId}`); + this._toolCallAgents.delete(toolCallKey); agent.respondToPermissionRequest(e.state.toolCallId, true); // Strip confirmationTitle so createToolReadyAction emits the // auto-approved (no-options) action. effective = { ...e, state: { ...e.state, confirmationTitle: undefined } }; } else if (effective.state.confirmationTitle) { // Make sure the agent is registered for the eventual `ChatToolCallConfirmed` response. - this._toolCallAgents.set(`${sessionKey}:${e.state.toolCallId}`, agent.id); + this._toolCallAgents.set(toolCallKey, agent.id); } this._stateManager.dispatchServerAction( sessionKey, @@ -1206,6 +1218,7 @@ export class AgentSideEffects extends Disposable { throw new Error(`ChatToolCallConfirmed must be handled on an AHP chat channel: ${channel}`); } const toolCallKey = `${channel}:${action.toolCallId}`; + const managedApprovalRequired = this._managedApprovalToolCalls.delete(toolCallKey); const agentId = this._toolCallAgents.get(toolCallKey); if (agentId) { this._toolCallAgents.delete(toolCallKey); @@ -1217,7 +1230,7 @@ export class AgentSideEffects extends Disposable { // When the user chose "Allow in this Session", add the tool // to the session's permissions so future calls are auto-approved. - if (action.approved) { + if (action.approved && !managedApprovalRequired) { this._permissionManager.handleToolCallConfirmed(channel, action.toolCallId, action.selectedOptionId); } break; @@ -1776,6 +1789,7 @@ export class AgentSideEffects extends Disposable { override dispose(): void { this._toolCallAgents.clear(); + this._managedApprovalToolCalls.clear(); this._toolCallTracker.clear(); super.dispose(); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index d0e793afb68909..51c4d887a89722 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -552,7 +552,9 @@ export class CopilotAgentSession extends Disposable { private readonly _autoApprovals = new Map(); private readonly _pendingAutoApprovals = new PendingRequestRegistry(); /** Pending permission requests awaiting a renderer-side decision. */ - private readonly _pendingPermissions = new PendingRequestRegistry(); + private readonly _pendingPermissions = new PendingRequestRegistry(); /** Cancels callbacks that began before or during an SDK abort. */ private readonly _abortCts = this._register(new MutableDisposable()); /** @@ -1374,7 +1376,9 @@ export class CopilotAgentSession extends Disposable { // Still pending permission, so this call may have errored while getting permission. // Go ahead and allow the call which will immediately see the buffered value. - this.respondToPermissionRequest(toolCallId, true); + if (this._pendingPermissions.getMetadata(toolCallId)?.managedApprovalRequired !== true) { + this.respondToPermissionRequest(toolCallId, true); + } } private _cancelMcpAuthenticationForToolCall(toolCallId: string): boolean { @@ -2265,7 +2269,8 @@ export class CopilotAgentSession extends Disposable { return { kind: 'reject' }; } - const autoApproval = this._lastAppliedPermissionMode === 'auto' + const managedApprovalRequired = request.managedApprovalRequired === true; + const autoApproval = !managedApprovalRequired && this._lastAppliedPermissionMode === 'auto' ? await this._takeAutoApproval(toolCallId) : undefined; const recommendation = autoApproval?.recommendation; @@ -2306,14 +2311,14 @@ export class CopilotAgentSession extends Disposable { const approvedSignature = this._approvedDuplicablePermissionSignatures.get(toolCallId); if (approvedSignature !== undefined) { this._approvedDuplicablePermissionSignatures.delete(toolCallId); - if ((request.kind === 'write' || request.kind === 'read') && safeStringify(request) === approvedSignature) { + if (!managedApprovalRequired && (request.kind === 'write' || request.kind === 'read') && safeStringify(request) === approvedSignature) { this._logService.info(`[Copilot:${this.sessionId}] Auto-approving duplicate ${request.kind} permission request for tool call ${toolCallId}`); return { kind: 'approve-once' }; } } const sessionResourcePath = this._getInternalSessionResourcePath(request); - if (sessionResourcePath) { + if (!managedApprovalRequired && sessionResourcePath) { this._logService.info(`[Copilot:${this.sessionId}] Auto-approving internal session resource ${sessionResourcePath}`); return { kind: 'approve-once' }; } @@ -2325,7 +2330,7 @@ export class CopilotAgentSession extends Disposable { // read those same files back, and prompting the user to // approve a read of bytes they themselves attached is // redundant. - if (request.kind === 'read' && typeof request.path === 'string' + if (!managedApprovalRequired && request.kind === 'read' && typeof request.path === 'string' && this._isSessionAttachmentPath(request.path) ) { this._logService.info(`[Copilot:${this.sessionId}] Auto-approving session attachment ${request.path}`); @@ -2336,7 +2341,7 @@ export class CopilotAgentSession extends Disposable { // Copilot SDK itself. The SDK spills oversized tool results to // `os.tmpdir()/copilot-tool-output-…txt` and then asks the model // to read them back in a follow-up turn — no need to confirm. - if (request.kind === 'read' && typeof request.path === 'string') { + if (!managedApprovalRequired && request.kind === 'read' && typeof request.path === 'string') { if (isCopilotSdkToolOutputTempFile(request.path, this._environmentService.tmpDir.fsPath)) { this._logService.info(`[Copilot:${this.sessionId}] Auto-approving Copilot SDK tool-output temp file ${request.path}`); return { kind: 'approve-once' }; @@ -2348,7 +2353,7 @@ export class CopilotAgentSession extends Disposable { // workspace, shell, or network, so prompting for them is redundant // noise. Tools that explicitly require confirmation (e.g. revealing // unreviewed review comments) are excluded so the user is prompted. - if (request.kind === 'custom-tool' && typeof request.toolName === 'string' + if (!managedApprovalRequired && request.kind === 'custom-tool' && typeof request.toolName === 'string' && this._serverToolHost?.toolNames.includes(request.toolName) && !this._serverToolHost.requiresConfirmation(request.toolName) ) { @@ -2359,7 +2364,7 @@ export class CopilotAgentSession extends Disposable { const isShellRequest = request.kind === 'shell' || (request.kind === 'custom-tool' && typeof request.toolName === 'string' && isShellTool(request.toolName)); - if (request.kind === 'custom-tool' + if (!managedApprovalRequired && request.kind === 'custom-tool' && typeof request.toolName === 'string' && this._clientToolNames.has(this._clientToolName(request.toolName)) && this._pendingClientToolCalls.hasBufferedResult(toolCallId) @@ -2370,7 +2375,7 @@ export class CopilotAgentSession extends Disposable { this._logService.info(`[Copilot:${this.sessionId}] Requesting confirmation for tool call: ${toolCallId}`); - const pendingPermission = this._pendingPermissions.register(toolCallId); + const pendingPermission = this._pendingPermissions.register(toolCallId, { managedApprovalRequired }); // Auto-approve shell commands that run sandboxed by default, since the // sandbox already contains them. Commands that opted OUT of the sandbox @@ -2378,7 +2383,7 @@ export class CopilotAgentSession extends Disposable { // fall through to the normal confirmation flow — otherwise enabling // `sandbox.allowBypass` would let the model escape the sandbox with no // prompt at all. - if (isShellRequest && !request.requestSandboxBypass && await this._isShellSandboxedByDefault()) { + if (!managedApprovalRequired && isShellRequest && !request.requestSandboxBypass && await this._isShellSandboxedByDefault()) { // Session may have been disposed while we awaited the engine // check; if so the deferred has already been settled and // removed, so leave it alone. @@ -2438,13 +2443,14 @@ export class CopilotAgentSession extends Disposable { }, permissionKind, permissionPath, + managedApprovalRequired, requestSandboxBypass: request.requestSandboxBypass, parentToolCallId, }); const result = await pendingPermission; this._logService.info(`[Copilot:${this.sessionId}] Permission response: toolCallId=${toolCallId}, result=${result.kind}`); - if (result.kind === 'approve-once' && (request.kind === 'write' || request.kind === 'read')) { + if (!managedApprovalRequired && result.kind === 'approve-once' && (request.kind === 'write' || request.kind === 'read')) { this._approvedDuplicablePermissionSignatures.set(toolCallId, safeStringify(request)); } return result; @@ -2730,7 +2736,7 @@ export class CopilotAgentSession extends Disposable { } private async _requestUnsandboxedCommandConfirmation(request: IUnsandboxedCommandConfirmationRequest): Promise { - const pendingPermission = this._pendingPermissions.register(request.toolCallId); + const pendingPermission = this._pendingPermissions.register(request.toolCallId, { managedApprovalRequired: false }); const displayName = getToolDisplayName(request.toolName); const blockedDomains = request.blockedDomains?.length ? request.blockedDomains.join(', ') : undefined; diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index a1a4a498621ccb..4f76a9a10915d9 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -1051,6 +1051,8 @@ export function tryStringify(value: unknown): string | undefined { export interface ITypedPermissionRequest { /** Permission kind discriminator from the SDK. */ kind: PermissionRequest['kind']; + /** Whether managed policy requires a human response and forbids host auto-approval. */ + managedApprovalRequired?: boolean; /** Tool call ID that triggered this permission request, when available. */ toolCallId?: string; /** File path — set for `read` permission requests. */ diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index cd79ea714f994c..e936157495b832 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -50,11 +50,14 @@ export interface IToolApprovalEvent { /** Standard per-tool confirmation options presented to the user. */ const ALLOW_SESSION_OPTION_ID = 'allow-session'; +const ALLOW_ONCE_OPTION: ConfirmationOption = { id: 'allow-once', label: localize('sessionPermissions.allowOnce', "Allow Once"), kind: ConfirmationOptionKind.Approve }; +const SKIP_OPTION: ConfirmationOption = { id: 'skip', label: localize('sessionPermissions.skip', "Skip"), kind: ConfirmationOptionKind.Deny, group: 2 }; const CONFIRMATION_OPTIONS: readonly ConfirmationOption[] = [ { id: ALLOW_SESSION_OPTION_ID, label: localize('sessionPermissions.allowSession', "Allow in this Session"), kind: ConfirmationOptionKind.Approve, group: 1 }, - { id: 'allow-once', label: localize('sessionPermissions.allowOnce', "Allow Once"), kind: ConfirmationOptionKind.Approve }, - { id: 'skip', label: localize('sessionPermissions.skip', "Skip"), kind: ConfirmationOptionKind.Deny, group: 2 }, + ALLOW_ONCE_OPTION, + SKIP_OPTION, ]; +const MANAGED_CONFIRMATION_OPTIONS: readonly ConfirmationOption[] = [ALLOW_ONCE_OPTION, SKIP_OPTION]; /** Default write-path glob rules applied to auto-approved edits. */ const DEFAULT_EDIT_AUTO_APPROVE_PATTERNS: Readonly> = { @@ -363,10 +366,14 @@ export class SessionPermissionManager extends Disposable { edits: state.edits, editable: state.editable, ...(state._meta ? { _meta: state._meta } : {}), - // Agents can supply tool-specific buttons (e.g. ExitPlanMode's - // `Approve`/`Deny`) by populating `state.options`. The standard - // `Allow Once / Allow in this Session / Skip` set is the default. - options: state.options ? state.options.slice() : CONFIRMATION_OPTIONS.slice(), + // Managed asks are one-time only. Other agents can supply tool-specific + // buttons (e.g. ExitPlanMode's `Approve`/`Deny`) via `state.options`; + // otherwise the standard session/once/skip set is used. + options: e.managedApprovalRequired + ? MANAGED_CONFIRMATION_OPTIONS.slice() + : state.options + ? state.options.slice() + : CONFIRMATION_OPTIONS.slice(), }; } return { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index a7155714f4ca58..874feec10520f3 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -26,11 +26,11 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; import { ChangesSummary, ChatOriginKind, CustomizationType, McpAuthRequiredReason, SessionInputRequestKind } from '../../common/state/protocol/state.js'; import { ActionType, ActionEnvelope, type ChatAction, type INotification, type SessionAction } from '../../common/state/sessionActions.js'; -import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInteractivity, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization, type Turn } from '../../common/state/sessionState.js'; +import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInteractivity, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization, type Turn } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; -import { AgentHostTelemetryLevelConfigKey, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; +import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTelemetryLevelConfigKey, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; @@ -5124,6 +5124,113 @@ suite('AgentSideEffects', () => { ]); }); + test('managed approval bypasses global, session, and per-tool auto-approval', async () => { + setupSession(); + stateManager.dispatchServerAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostGlobalAutoApproveEnabledConfigKey]: true }, + }); + stateManager.setSessionConfig(sessionUri.toString(), { + schema: { type: 'object', properties: {} }, + values: { + [SessionConfigKey.AutoApprove]: 'autoApprove', + [SessionConfigKey.Permissions]: { allow: ['CustomTool'], deny: [] }, + }, + }); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), + action: { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: 'tc-managed', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined, + }, + }); + agent.fireProgress({ + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-managed', toolName: 'CustomTool', displayName: 'Custom Tool', + invocationMessage: 'Run managed custom tool', toolInput: undefined, + confirmationTitle: 'Run managed custom tool', edits: undefined, + }, + permissionKind: 'custom-tool', + managedApprovalRequired: true, + }); + + const toolCall = await waitForState(stateManager, () => { + const part = stateManager.getSessionState(sessionUri.toString())?.activeTurn?.responseParts.find( + responsePart => responsePart.kind === ResponsePartKind.ToolCall && responsePart.toolCall.toolCallId === 'tc-managed' + ); + return part?.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.PendingConfirmation + ? part.toolCall + : undefined; + }); + + assert.deepStrictEqual({ + status: toolCall.status, + options: toolCall.options?.map(option => option.id), + responses: agent.respondToPermissionCalls, + }, { + status: ToolCallStatus.PendingConfirmation, + options: ['allow-once', 'skip'], + responses: [], + }); + }); + + test('managed approval does not persist allow-session from the client', async () => { + setupSession(); + stateManager.setSessionConfig(sessionUri.toString(), { + schema: { type: 'object', properties: {} }, + values: { permissions: { allow: ['ExistingTool'], deny: [] } }, + }); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), + action: { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: 'tc-managed', toolName: 'ManagedTool', displayName: 'Managed Tool', contributor: undefined, + }, + }); + agent.fireProgress({ + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-managed', toolName: 'ManagedTool', displayName: 'Managed Tool', + invocationMessage: 'Run managed tool', toolInput: undefined, + confirmationTitle: 'Run managed tool', edits: undefined, + }, + permissionKind: 'custom-tool', + managedApprovalRequired: true, + }); + + await waitForState(stateManager, () => { + const part = stateManager.getSessionState(sessionUri.toString())?.activeTurn?.responseParts.find( + responsePart => responsePart.kind === ResponsePartKind.ToolCall && responsePart.toolCall.toolCallId === 'tc-managed' + ); + return part?.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.PendingConfirmation; + }); + sideEffects.handleAction(defaultChatUri, { + type: ActionType.ChatToolCallConfirmed, + turnId: 'turn-1', + toolCallId: 'tc-managed', + approved: true, + confirmed: 'user-action', + selectedOptionId: 'allow-session', + } as ChatAction); + + assert.deepStrictEqual(agent.respondToPermissionCalls, [ + { requestId: 'tc-managed', approved: true }, + ]); + assert.deepStrictEqual( + stateManager.getSessionState(sessionUri.toString())?.config?.values[SessionConfigKey.Permissions], + { allow: ['ExistingTool'], deny: [] }, + ); + }); + test('subagent tool calls inherit parent session permissions', async () => { setupSession(); stateManager.setSessionConfig(sessionUri.toString(), { diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 4605c5533c2865..3b3a9c952e9a61 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -2310,6 +2310,126 @@ suite('CopilotAgentSession', () => { }); }); + test('managed approval requires confirmation despite an approve recommendation', async () => { + const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { + configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, + }); + await session.syncPermissionMode('turn-start'); + mockSession.fire('permission.requested', { + requestId: 'request-managed', + permissionRequest: { + kind: 'read', + path: '/workspace/src/file.ts', + intention: 'Read the file', + toolCallId: 'tc-managed', + managedApprovalRequired: true, + }, + promptRequest: { + kind: 'path', + accessKind: 'read', + paths: ['/workspace/src/file.ts'], + toolCallId: 'tc-managed', + managedApprovalRequired: true, + autoApproval: { recommendation: 'approve', reason: 'Low risk' }, + }, + }); + + const resultPromise = runtime.handlePermissionRequest({ + kind: 'read', + path: '/workspace/src/file.ts', + toolCallId: 'tc-managed', + managedApprovalRequired: true, + }); + + await waitForSignal(signal => signal.kind === 'pending_confirmation'); + assert.strictEqual(signals.length, 1); + assert.strictEqual(signals[0].kind === 'pending_confirmation' && signals[0].managedApprovalRequired, true); + assert.ok(session.respondToPermissionRequest('tc-managed', true)); + assert.strictEqual((await resultPromise).kind, 'approve-once'); + }); + + test('managed approval requires confirmation under global and session allow-all modes', async () => { + const testCases = [ + { + name: 'global', + options: { rootValues: { [AgentHostGlobalAutoApproveEnabledConfigKey]: true } }, + }, + { + name: 'session', + options: { configValues: { [SessionConfigKey.AutoApprove]: 'autoApprove' } }, + }, + ]; + + for (const testCase of testCases) { + const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables, testCase.options); + await session.syncPermissionMode('turn-start'); + const toolCallId = `tc-managed-${testCase.name}`; + const resultPromise = runtime.handlePermissionRequest({ + kind: 'read', + path: '/workspace/src/file.ts', + toolCallId, + managedApprovalRequired: true, + }); + + await waitForSignal(signal => signal.kind === 'pending_confirmation'); + assert.deepStrictEqual({ + managedApprovalRequired: signals[0].kind === 'pending_confirmation' ? signals[0].managedApprovalRequired : undefined, + responded: session.respondToPermissionRequest(toolCallId, false), + result: await resultPromise, + }, { + managedApprovalRequired: true, + responded: true, + result: { kind: 'denied-interactively-by-user' }, + }, testCase.name); + disposables.clear(); + } + }); + + test('managed read and write approvals do not auto-approve duplicate requests', async () => { + const testCases = [ + { + name: 'read', + request: { + kind: 'read' as const, + path: '/workspace/src/file.ts', + toolCallId: 'tc-managed-duplicate-read', + managedApprovalRequired: true, + }, + }, + { + name: 'write', + request: { + kind: 'write' as const, + fileName: '/workspace/src/file.ts', + toolCallId: 'tc-managed-duplicate-write', + managedApprovalRequired: true, + }, + }, + ]; + + for (const testCase of testCases) { + const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); + const firstResultPromise = runtime.handlePermissionRequest(testCase.request); + await waitForSignal(signal => signal.kind === 'pending_confirmation'); + assert.ok(session.respondToPermissionRequest(testCase.request.toolCallId, true)); + const firstResult = await firstResultPromise; + + const duplicateResultPromise = runtime.handlePermissionRequest({ ...testCase.request }); + await timeout(0); + const pendingConfirmationCount = signals.filter(signal => signal.kind === 'pending_confirmation').length; + assert.ok(session.respondToPermissionRequest(testCase.request.toolCallId, false)); + + assert.deepStrictEqual({ + results: [firstResult, await duplicateResultPromise], + pendingConfirmationCount, + }, { + results: [{ kind: 'approve-once' }, { kind: 'denied-interactively-by-user' }], + pendingConfirmationCount: 2, + }, testCase.name); + disposables.clear(); + } + }); + test('Approve When Safe correlates a recommendation event that arrives after the permission callback', async () => { const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, @@ -5681,6 +5801,44 @@ suite('CopilotAgentSession', () => { await resultPromise; }); + test('client tool completion does not approve a pending managed permission', async () => { + const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { + clientSnapshot: snapshot, + activeClientToolSet: activeClientToolSetWith('test-client'), + }); + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-managed-client', + toolName: 'my_tool', + arguments: { file: 'test.ts' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + const permissionPromise = runtime.handlePermissionRequest({ + kind: 'custom-tool', + toolCallId: 'tc-managed-client', + toolName: 'my_tool', + managedApprovalRequired: true, + }); + await waitForSignal(signal => signal.kind === 'pending_confirmation'); + + session.handleClientToolCallComplete('tc-managed-client', { + success: true, + pastTenseMessage: 'did it', + content: [{ type: ToolResultContentType.Text, text: 'result text' }], + }); + let permissionResult: Awaited | undefined; + void permissionPromise.then(result => permissionResult = result); + await timeout(0); + + assert.deepStrictEqual({ + permissionResult, + pendingConfirmations: signals.filter(signal => signal.kind === 'pending_confirmation').length, + }, { + permissionResult: undefined, + pendingConfirmations: 1, + }); + assert.ok(session.respondToPermissionRequest('tc-managed-client', false)); + assert.deepStrictEqual(await permissionPromise, { kind: 'denied-interactively-by-user' }); + }); + test('handleClientToolCallComplete with content containing embedded resources', async () => { const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); @@ -5849,6 +6007,43 @@ suite('CopilotAgentSession', () => { }, }); }); + + test('buffered client tool completion does not approve a managed permission', async () => { + const activeClientToolSet = new ActiveClientToolSet(); + activeClientToolSet.set('test-client', snapshot.tools); + const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-managed-buffered', + toolName: 'my_tool', + arguments: {}, + } as SessionEventPayload<'tool.execution_start'>['data']); + session.handleClientToolCallComplete('tc-managed-buffered', { + success: true, + pastTenseMessage: 'did it', + content: [{ type: ToolResultContentType.Text, text: 'buffered result' }], + }); + + const permissionPromise = runtime.handlePermissionRequest({ + kind: 'custom-tool', + toolCallId: 'tc-managed-buffered', + toolName: 'my_tool', + managedApprovalRequired: true, + }); + await waitForSignal(signal => signal.kind === 'pending_confirmation'); + let permissionResult: Awaited | undefined; + void permissionPromise.then(result => permissionResult = result); + await timeout(0); + + assert.deepStrictEqual({ + permissionResult, + pendingConfirmations: signals.filter(signal => signal.kind === 'pending_confirmation').length, + }, { + permissionResult: undefined, + pendingConfirmations: 1, + }); + assert.ok(session.respondToPermissionRequest('tc-managed-buffered', false)); + assert.deepStrictEqual(await permissionPromise, { kind: 'denied-interactively-by-user' }); + }); }); // ---- Server tools -------------------------------------------------------