Skip to content

Commit c30ed7c

Browse files
authored
feat(agent-host): gate inbound filesystem RPCs with a permission service (#314194)
* feat(agent-host): gate inbound filesystem RPCs with a permission service Reverse `resource{Read,List,Write,Delete,Move}` from remote agent hosts were routed straight to `IFileService` with no authorization. Add a permission service that gates each reverse RPC, returns typed `PermissionDenied` with `data.request`, handles negotiation via the new `resourceRequest` reverse RPC, and surfaces a Deny / Allow / Always Allow prompt above the chat input. URIs are canonicalized through `IFileService.realpath` before comparison so `..` and symlinks can't escape grants. Implicit read grants are auto-registered for customization URIs the client sends to the host, so plugin sync remains friction-free. Always-Allow grants persist into a new user setting, `chat.agentHost.localFilePermissions`. * comments and tests
1 parent a2d447b commit c30ed7c

21 files changed

Lines changed: 2384 additions & 83 deletions

src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts

Lines changed: 166 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
// higher-level API matching IAgentService.
99

1010
import { DeferredPromise } from '../../../base/common/async.js';
11+
import { CancellationError } from '../../../base/common/errors.js';
1112
import { Emitter } from '../../../base/common/event.js';
1213
import { Disposable, IReference } from '../../../base/common/lifecycle.js';
1314
import { Schemas } from '../../../base/common/network.js';
@@ -19,36 +20,25 @@ import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCod
1920
import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js';
2021
import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js';
2122
import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js';
23+
import { AgentHostPermissionMode, IAgentHostPermissionService } from '../common/agentHostPermissionService.js';
2224
import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js';
23-
import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js';
24-
import { SessionSummary, SessionStatus, ROOT_STATE_URI, StateComponents, type RootState } from '../common/state/sessionState.js';
25+
import { ActionType, type ActionEnvelope, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js';
26+
import { SessionSummary, SessionStatus, ROOT_STATE_URI, StateComponents, type CustomizationRef, type RootState } from '../common/state/sessionState.js';
2527
import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js';
26-
import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js';
28+
import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js';
2729
import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js';
2830
import { AhpErrorCodes } from '../common/state/protocol/errors.js';
29-
import { ContentEncoding, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js';
31+
import { ContentEncoding, ResourceRequestParams, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js';
3032
import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js';
3133

3234
const AHP_CLIENT_CONNECTION_CLOSED = -32000;
3335

34-
export class RemoteAgentHostProtocolError extends Error {
35-
36-
readonly code: number;
37-
readonly data: unknown | undefined;
38-
39-
constructor(error: JsonRpcErrorResponse['error']) {
40-
super(error.message);
41-
this.code = error.code;
42-
this.data = error.data;
43-
}
44-
45-
static connectionClosed(address: string): RemoteAgentHostProtocolError {
46-
return new RemoteAgentHostProtocolError({ code: AHP_CLIENT_CONNECTION_CLOSED, message: `Connection closed: ${address}` });
47-
}
36+
function connectionClosedError(address: string): ProtocolError {
37+
return new ProtocolError(AHP_CLIENT_CONNECTION_CLOSED, `Connection closed: ${address}`);
38+
}
4839

49-
static disposed(address: string): RemoteAgentHostProtocolError {
50-
return new RemoteAgentHostProtocolError({ code: AHP_CLIENT_CONNECTION_CLOSED, message: `Connection disposed: ${address}` });
51-
}
40+
function connectionDisposedError(address: string): ProtocolError {
41+
return new ProtocolError(AHP_CLIENT_CONNECTION_CLOSED, `Connection disposed: ${address}`);
5242
}
5343

5444
interface IRemoteAgentHostExtensionCommandMap {
@@ -89,7 +79,14 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
8979
private readonly _pendingRequests = new Map<number, DeferredPromise<unknown>>();
9080
private _nextRequestId = 1;
9181
private _isClosed = false;
92-
private _closeError: RemoteAgentHostProtocolError | undefined;
82+
private _closeError: ProtocolError | undefined;
83+
84+
/**
85+
* Comparison keys of customization URIs we have already granted implicit
86+
* read access for on this connection. Dedupes repeat sends so we don't
87+
* pile up grants per dispatch. Cleared with the connection.
88+
*/
89+
private readonly _grantedCustomizationUris = new Set<string>();
9390

9491
get clientId(): string {
9592
return this._clientId;
@@ -108,14 +105,15 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
108105
transport: IProtocolTransport,
109106
@ILogService private readonly _logService: ILogService,
110107
@IFileService private readonly _fileService: IFileService,
108+
@IAgentHostPermissionService private readonly _permissionService: IAgentHostPermissionService,
111109
) {
112110
super();
113111
this._address = address;
114112
this._connectionAuthority = agentHostAuthority(address);
115113
this._transport = transport;
116114
this._register(this._transport);
117115
this._register(this._transport.onMessage(msg => this._handleMessage(msg)));
118-
this._register(this._transport.onClose(() => this._handleClose(RemoteAgentHostProtocolError.connectionClosed(this._address))));
116+
this._register(this._transport.onClose(() => this._handleClose(connectionClosedError(this._address))));
119117

120118
this._subscriptionManager = this._register(new AgentSubscriptionManager(
121119
this._clientId,
@@ -132,7 +130,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
132130
}
133131

134132
override dispose(): void {
135-
this._handleClose(RemoteAgentHostProtocolError.disposed(this._address));
133+
this._handleClose(connectionDisposedError(this._address));
136134
super.dispose();
137135
}
138136

@@ -206,6 +204,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
206204
* Dispatch a client action to the server. Returns the clientSeq used.
207205
*/
208206
dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, _clientId: string, clientSeq: number): void {
207+
this._grantImplicitReadsForOutgoingAction(action);
209208
this._sendNotification('dispatchAction', { clientSeq, action });
210209
}
211210

@@ -218,6 +217,9 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
218217
throw new Error('Cannot create remote agent host session without a provider.');
219218
}
220219
const session = config?.session ?? AgentSession.uri(provider, generateUuid());
220+
if (config?.activeClient?.customizations) {
221+
this._grantImplicitReadsForCustomizations(config.activeClient.customizations);
222+
}
221223
await this._sendRequest('createSession', {
222224
session: session.toString(),
223225
provider,
@@ -312,6 +314,43 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
312314
return uri.scheme === Schemas.file ? toAgentHostUri(uri, this._connectionAuthority) : uri;
313315
}
314316

317+
/**
318+
* Inspect an outgoing client-dispatched action and grant implicit reads
319+
* for any customization URIs it carries. Today this covers
320+
* `SessionActiveClientChanged`, which is the only client-dispatched
321+
* action that ships customization URIs to the host.
322+
*/
323+
private _grantImplicitReadsForOutgoingAction(action: SessionAction | TerminalAction | IRootConfigChangedAction): void {
324+
if (action.type === ActionType.SessionActiveClientChanged && action.activeClient?.customizations) {
325+
this._grantImplicitReadsForCustomizations(action.activeClient.customizations);
326+
}
327+
}
328+
329+
/**
330+
* Register implicit read grants for each customization URI that we are
331+
* about to send to the host. The host needs to read these to materialize
332+
* the customization, but should not need to write them. Grants are
333+
* deduped per connection and revoked when the connection closes.
334+
*/
335+
private _grantImplicitReadsForCustomizations(refs: readonly CustomizationRef[]): void {
336+
for (const ref of refs) {
337+
let uri: URI;
338+
try {
339+
uri = URI.parse(ref.uri);
340+
} catch {
341+
continue;
342+
}
343+
const key = uri.toString();
344+
if (this._grantedCustomizationUris.has(key)) {
345+
continue;
346+
}
347+
this._grantedCustomizationUris.add(key);
348+
// Disposable is owned by the permission service; cleared on
349+
// connectionClosed.
350+
this._permissionService.grantImplicitRead(this._address, uri);
351+
}
352+
}
353+
315354
/**
316355
* List the contents of a directory on the remote host's filesystem.
317356
*/
@@ -382,14 +421,16 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
382421
}
383422
}
384423

385-
private _handleClose(error: RemoteAgentHostProtocolError): void {
424+
private _handleClose(error: ProtocolError): void {
386425
if (this._isClosed) {
387426
return;
388427
}
389428

390429
this._isClosed = true;
391430
this._closeError = error;
392431
this._rejectPendingRequests(error);
432+
this._permissionService.connectionClosed(this._address);
433+
this._grantedCustomizationUris.clear();
393434
this._onDidClose.fire();
394435
}
395436

@@ -413,6 +454,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
413454
/**
414455
* Handles reverse RPC requests from the server (e.g. resourceList,
415456
* resourceRead). Reads from the local file service and sends a response.
457+
*
458+
* Filesystem-mutating reverse requests are gated through
459+
* {@link IAgentHostPermissionService} — denied operations return a typed
460+
* `PermissionDenied` error advertising a `resourceRequest` payload that,
461+
* if granted, would unlock the operation. Hosts SHOULD then issue a
462+
* `resourceRequest` and retry.
416463
*/
417464
private _handleReverseRequest(id: number, method: string, params: unknown): void {
418465
const sendResult = (result: unknown) => {
@@ -428,28 +475,65 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
428475
}
429476
this._transport.send({ jsonrpc: '2.0', id, error: { code, message: err instanceof Error ? err.message : String(err) } });
430477
};
431-
const handle = (fn: () => Promise<unknown>) => {
432-
fn().then(sendResult, sendError);
478+
const sendPermissionDenied = (request: ResourceRequestParams | undefined) => {
479+
this._transport.send({
480+
jsonrpc: '2.0',
481+
id,
482+
error: {
483+
code: AhpErrorCodes.PermissionDenied,
484+
message: request
485+
? `Access to ${request.uri} is not granted.`
486+
: 'Access to the requested resource is not granted.',
487+
data: request ? { request } : undefined,
488+
},
489+
});
490+
};
491+
492+
/**
493+
* Runs `fn` if the permission service grants access for `(uri, mode)`.
494+
* Otherwise replies with `PermissionDenied` advertising the request
495+
* that, if granted, would unlock the operation. Errors thrown from
496+
* `fn` are reported via `sendError`.
497+
*/
498+
const gateAndHandle = async (
499+
uri: URI,
500+
mode: AgentHostPermissionMode,
501+
deniedRequest: ResourceRequestParams,
502+
fn: () => Promise<unknown>,
503+
): Promise<void> => {
504+
try {
505+
if (!await this._permissionService.check(this._address, uri, mode)) {
506+
sendPermissionDenied(deniedRequest);
507+
return;
508+
}
509+
sendResult(await fn());
510+
} catch (err) {
511+
sendError(err);
512+
}
433513
};
434514

435515
const p = params as Record<string, unknown>;
436516
switch (method) {
437-
case 'resourceList':
517+
case 'resourceList': {
438518
if (!p.uri) { sendError(new Error('Missing uri')); return; }
439-
return handle(async () => {
440-
const stat = await this._fileService.resolve(URI.parse(p.uri as string));
519+
const uri = URI.parse(p.uri as string);
520+
return void gateAndHandle(uri, AgentHostPermissionMode.Read, { uri: uri.toString(), read: true }, async () => {
521+
const stat = await this._fileService.resolve(uri);
441522
return { entries: (stat.children ?? []).map(c => ({ name: c.name, type: c.isDirectory ? 'directory' as const : 'file' as const })) };
442523
});
443-
case 'resourceRead':
524+
}
525+
case 'resourceRead': {
444526
if (!p.uri) { sendError(new Error('Missing uri')); return; }
445-
return handle(async () => {
446-
const content = await this._fileService.readFile(URI.parse(p.uri as string));
527+
const uri = URI.parse(p.uri as string);
528+
return void gateAndHandle(uri, AgentHostPermissionMode.Read, { uri: uri.toString(), read: true }, async () => {
529+
const content = await this._fileService.readFile(uri);
447530
return { data: encodeBase64(content.value), encoding: ContentEncoding.Base64 };
448531
});
449-
case 'resourceWrite':
532+
}
533+
case 'resourceWrite': {
450534
if (!p.uri || !p.data) { sendError(new Error('Missing uri or data')); return; }
451-
return handle(async () => {
452-
const writeUri = URI.parse(p.uri as string);
535+
const writeUri = URI.parse(p.uri as string);
536+
return void gateAndHandle(writeUri, AgentHostPermissionMode.Write, { uri: writeUri.toString(), write: true }, async () => {
453537
const buf = p.encoding === ContentEncoding.Base64
454538
? decodeBase64(p.data as string)
455539
: VSBuffer.fromString(p.data as string);
@@ -460,12 +544,51 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
460544
}
461545
return {};
462546
});
463-
case 'resourceDelete':
547+
}
548+
case 'resourceDelete': {
464549
if (!p.uri) { sendError(new Error('Missing uri')); return; }
465-
return handle(() => this._fileService.del(URI.parse(p.uri as string), { recursive: !!p.recursive }).then(() => ({})));
466-
case 'resourceMove':
550+
const deleteUri = URI.parse(p.uri as string);
551+
return void gateAndHandle(deleteUri, AgentHostPermissionMode.Write, { uri: deleteUri.toString(), write: true }, () =>
552+
this._fileService.del(deleteUri, { recursive: !!p.recursive }).then(() => ({})));
553+
}
554+
case 'resourceMove': {
467555
if (!p.source || !p.destination) { sendError(new Error('Missing source or destination')); return; }
468-
return handle(() => this._fileService.move(URI.parse(p.source as string), URI.parse(p.destination as string), !p.failIfExists).then(() => ({})));
556+
const sourceUri = URI.parse(p.source as string);
557+
const destUri = URI.parse(p.destination as string);
558+
return void (async () => {
559+
try {
560+
const [sourceOk, destOk] = await Promise.all([
561+
this._permissionService.check(this._address, sourceUri, AgentHostPermissionMode.Write),
562+
this._permissionService.check(this._address, destUri, AgentHostPermissionMode.Write),
563+
]);
564+
if (!sourceOk) {
565+
sendPermissionDenied({ uri: sourceUri.toString(), write: true });
566+
return;
567+
}
568+
if (!destOk) {
569+
sendPermissionDenied({ uri: destUri.toString(), write: true });
570+
return;
571+
}
572+
await this._fileService.move(sourceUri, destUri, !p.failIfExists);
573+
sendResult({});
574+
} catch (err) {
575+
sendError(err);
576+
}
577+
})();
578+
}
579+
case 'resourceRequest': {
580+
const requestParams = p as unknown as ResourceRequestParams;
581+
this._permissionService.request(this._address, requestParams)
582+
.then(() => sendResult({}))
583+
.catch(err => {
584+
if (err instanceof CancellationError) {
585+
sendPermissionDenied(undefined);
586+
} else {
587+
sendError(err);
588+
}
589+
});
590+
return;
591+
}
469592
default:
470593
this._logService.warn(`[RemoteAgentHostProtocol] Unhandled reverse request: ${method}`);
471594
sendError(new Error(`Unknown method: ${method}`));
@@ -508,11 +631,11 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
508631
return deferred.p as Promise<IRemoteAgentHostExtensionCommandMap[M]['result']>;
509632
}
510633

511-
private _toProtocolError(error: JsonRpcErrorResponse['error']): RemoteAgentHostProtocolError {
512-
return new RemoteAgentHostProtocolError(error);
634+
private _toProtocolError(error: JsonRpcErrorResponse['error']): ProtocolError {
635+
return new ProtocolError(error.code, error.message, error.data);
513636
}
514637

515-
private _rejectPendingRequests(error: RemoteAgentHostProtocolError): void {
638+
private _rejectPendingRequests(error: ProtocolError): void {
516639
for (const pending of this._pendingRequests.values()) {
517640
pending.error(error);
518641
}

0 commit comments

Comments
 (0)