Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 20 additions & 6 deletions src/vs/platform/agentHost/node/agentSideEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
/** Managed confirmations are human-only and must never seed host-side session permissions. */
private readonly _managedApprovalToolCalls = new Set<string>();
private _lastAgentInfos: readonly AgentInfo[] = [];

private readonly _permissionManager: SessionPermissionManager;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -1776,6 +1789,7 @@ export class AgentSideEffects extends Disposable {

override dispose(): void {
this._toolCallAgents.clear();
this._managedApprovalToolCalls.clear();
this._toolCallTracker.clear();
super.dispose();
}
Expand Down
32 changes: 19 additions & 13 deletions src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,9 @@ export class CopilotAgentSession extends Disposable {
private readonly _autoApprovals = new Map<string, PermissionAutoApproval | null>();
private readonly _pendingAutoApprovals = new PendingRequestRegistry<PermissionAutoApproval | undefined>();
/** Pending permission requests awaiting a renderer-side decision. */
private readonly _pendingPermissions = new PendingRequestRegistry<PermissionRequestResult>();
private readonly _pendingPermissions = new PendingRequestRegistry<PermissionRequestResult, {
readonly managedApprovalRequired: boolean;
}>();
/** Cancels callbacks that began before or during an SDK abort. */
private readonly _abortCts = this._register(new MutableDisposable<CancellationTokenSource>());
/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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'
Comment thread
joshspicer marked this conversation as resolved.
? await this._takeAutoApproval(toolCallId)
: undefined;
const recommendation = autoApproval?.recommendation;
Expand Down Expand Up @@ -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' };
}
Expand All @@ -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}`);
Expand All @@ -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' };
Expand All @@ -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)
) {
Expand All @@ -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)
Expand All @@ -2370,15 +2375,15 @@ 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
// (`requestSandboxBypass`) are an elevation of privilege and must
// 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 @@ -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;
Expand Down Expand Up @@ -2730,7 +2736,7 @@ export class CopilotAgentSession extends Disposable {
}

private async _requestUnsandboxedCommandConfirmation(request: IUnsandboxedCommandConfirmationRequest): Promise<boolean> {
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;
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
19 changes: 13 additions & 6 deletions src/vs/platform/agentHost/node/sessionPermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, boolean>> = {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading