Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/vs/platform/agentHost/node/agentSideEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 11 additions & 9 deletions src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment on lines +2144 to +2145
? await this._takeAutoApproval(toolCallId)
: undefined;
const recommendation = autoApproval?.recommendation;
Expand Down Expand Up @@ -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' };
}
Expand All @@ -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}`);
Expand All @@ -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' };
Expand All @@ -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)
) {
Expand All @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
48 changes: 48 additions & 0 deletions src/vs/platform/agentHost/test/node/agentSideEffects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(), {
Expand Down
38 changes: 38 additions & 0 deletions src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Loading