diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index d07b370326e687..802820d393c87a 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -1259,6 +1259,8 @@ 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. */ + 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 86ef43860ddcbf..0f0fcbeb76dafd 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -1060,7 +1060,9 @@ 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 diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 5dd3914cc7a0d4..0a7b0a336213ed 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -2141,7 +2141,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; @@ -2182,14 +2183,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' }; } @@ -2201,7 +2202,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}`); @@ -2212,7 +2213,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' }; @@ -2224,7 +2225,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) ) { @@ -2235,7 +2236,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) @@ -2255,7 +2256,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. @@ -2315,13 +2316,14 @@ export class CopilotAgentSession extends Disposable { }, permissionKind, permissionPath, + managedApprovalRequired, requestSandboxBypass: request.requestSandboxBypass, parentToolCallId, }); const result = await deferred.p; 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; 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/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 84cfbf2aaff2b8..e1fb538f59ff07 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -4745,6 +4745,54 @@ suite('AgentSideEffects', () => { ]); }); + test('managed approval bypasses session auto-approval and reaches the client confirmation UI', async () => { + setupSession(); + stateManager.setSessionConfig(sessionUri.toString(), { + schema: { type: 'object', properties: {} }, + values: { 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-session', 'allow-once', 'skip'], + responses: [], + }); + }); + 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 ca27d36791478a..e22d73f792fc31 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -2146,6 +2146,44 @@ 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('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' },