mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-10 08:45:56 +01:00
411a997c16
* agent host: bring tool approval to parity with the extension - The agent host auto-approved write tool calls using only working-directory and glob checks, while the extension's edit-confirmation pipeline also guards against unsafe paths, system/platform-config locations, and symlinks that redirect edits outside the workspace; this closes that gap so the agent host no longer silently approves edits the extension would prompt for. - Resolves symlinks (including not-yet-created ancestors) so a link cannot be used to escape the working directory, and treats permission-denied resolution as requiring confirmation. - Makes the approval path async rather than relying on synchronous filesystem calls, while keeping the shell-command approver synchronous so its existing behavior and tests are unaffected. - Adds unit tests covering the new path-safety, platform-restriction, and symlink-escape checks. (Commit message generated by Copilot) * agent host: address review feedback and fix async approval tests - Treat EACCES like EPERM when resolving symlinks so an execute-only directory cannot be used to bypass the working-directory check; permission errors now require confirmation rather than silently checking the literal path. - Fix mis-indented JSDoc in claudeCanUseTool. - Update AgentSideEffects tests to await the now-async tool-approval dispatch via a deterministic waitForState helper (re-checks on each state emit) instead of depending on a fixed timing delay. (Commit message generated by Copilot) * agent host: fix SessionPermissionManager test on Windows CI - The test built its working directory under `os.tmpdir()`, which on Windows CI is an 8.3 short path (`C:\Users\RUNNER~1\...`). `assertPathIsSafe` correctly rejects the `~1` segment, so every auto-approval returned "needs confirmation" and the suite failed. Base the temp dir on `RUNNER_TEMP` (a plain long path) when available so the working directory is free of 8.3 short names. (Commit message generated by Copilot)
143 lines
6.8 KiB
TypeScript
143 lines
6.8 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import assert from 'assert';
|
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'fs';
|
|
import { homedir, tmpdir } from 'os';
|
|
import { DisposableStore } from '../../../../base/common/lifecycle.js';
|
|
import { join } from '../../../../base/common/path.js';
|
|
import { isWindows } from '../../../../base/common/platform.js';
|
|
import { URI } from '../../../../base/common/uri.js';
|
|
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
|
import { NullLogService } from '../../../log/common/log.js';
|
|
import { platformSessionSchema } from '../../common/agentHostSchema.js';
|
|
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
|
|
import { SessionStatus, ToolCallConfirmationReason, type SessionSummary } from '../../common/state/sessionState.js';
|
|
import { AgentConfigurationService } from '../../node/agentConfigurationService.js';
|
|
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
|
|
import { SessionPermissionManager, type IToolApprovalEvent } from '../../node/sessionPermissions.js';
|
|
|
|
suite('SessionPermissionManager', () => {
|
|
|
|
const disposables = new DisposableStore();
|
|
let manager: AgentHostStateManager;
|
|
let permissions: SessionPermissionManager;
|
|
|
|
// Real (symlink-resolved) temp directories so that the symlink-resolution
|
|
// checks compare like-for-like (e.g. macOS `/var` -> `/private/var`).
|
|
let workDir: string;
|
|
let outsideDir: string;
|
|
|
|
const sessionUri = URI.from({ scheme: 'copilot', path: '/s' }).toString();
|
|
|
|
function makeSummary(resource: string, workingDirectory?: string): SessionSummary {
|
|
return {
|
|
resource,
|
|
provider: 'copilot',
|
|
title: 't',
|
|
status: SessionStatus.Idle,
|
|
createdAt: Date.now(),
|
|
modifiedAt: Date.now(),
|
|
project: { uri: 'file:///project', displayName: 'Project' },
|
|
workingDirectory,
|
|
};
|
|
}
|
|
|
|
function writeEvent(permissionPath: string): IToolApprovalEvent {
|
|
return { toolCallId: 'tc-1', session: URI.parse(sessionUri), permissionKind: 'write', permissionPath };
|
|
}
|
|
|
|
setup(async () => {
|
|
// Prefer the CI runner temp dir (a plain long path) over `os.tmpdir()`,
|
|
// which on Windows CI is an 8.3 short path (`C:\Users\RUNNER~1\...`) that
|
|
// `assertPathIsSafe` rejects for its `~1` segment — which would make every
|
|
// auto-approval fail. `realpathSync` keeps macOS `/var` -> `/private/var`
|
|
// consistent so the symlink-resolution checks compare like-for-like.
|
|
const baseTmp = process.env.RUNNER_TEMP || tmpdir();
|
|
workDir = realpathSync(mkdtempSync(join(baseTmp, 'sesperm-work-')));
|
|
outsideDir = realpathSync(mkdtempSync(join(baseTmp, 'sesperm-out-')));
|
|
|
|
manager = disposables.add(new AgentHostStateManager(new NullLogService()));
|
|
const configService = disposables.add(new AgentConfigurationService(manager, new NullLogService()));
|
|
permissions = disposables.add(new SessionPermissionManager(manager, configService, new NullLogService()));
|
|
await permissions.initialize();
|
|
|
|
manager.createSession(makeSummary(sessionUri, URI.file(workDir).toString()));
|
|
});
|
|
|
|
teardown(() => {
|
|
disposables.clear();
|
|
rmSync(workDir, { recursive: true, force: true });
|
|
rmSync(outsideDir, { recursive: true, force: true });
|
|
});
|
|
|
|
ensureNoDisposablesAreLeakedInTestSuite();
|
|
|
|
test('auto-approves a normal file inside the working directory', async () => {
|
|
const result = await permissions.getAutoApproval(writeEvent(join(workDir, 'src', 'app.ts')), sessionUri);
|
|
assert.strictEqual(result, ToolCallConfirmationReason.NotNeeded);
|
|
});
|
|
|
|
test('requires confirmation for writes outside the working directory', async () => {
|
|
const result = await permissions.getAutoApproval(writeEvent(join(outsideDir, 'app.ts')), sessionUri);
|
|
assert.strictEqual(result, undefined);
|
|
});
|
|
|
|
test('requires confirmation for protected files inside the working directory', async () => {
|
|
const files = ['.env', 'package.json', join('.git', 'config'), 'deps.lock', join('.vscode', 'settings.json')];
|
|
const results: (ToolCallConfirmationReason | undefined)[] = [];
|
|
for (const file of files) {
|
|
results.push(await permissions.getAutoApproval(writeEvent(join(workDir, file)), sessionUri));
|
|
}
|
|
assert.deepStrictEqual(results, files.map(() => undefined));
|
|
});
|
|
|
|
test('requires confirmation for paths containing null bytes', async () => {
|
|
const result = await permissions.getAutoApproval(writeEvent(join(workDir, 'a\u0000b.txt')), sessionUri);
|
|
assert.strictEqual(result, undefined);
|
|
});
|
|
|
|
(isWindows ? test.skip : test)('requires confirmation when a symlink redirects outside the working directory', async () => {
|
|
symlinkSync(outsideDir, join(workDir, 'link'), 'dir');
|
|
const result = await permissions.getAutoApproval(writeEvent(join(workDir, 'link', 'secret.txt')), sessionUri);
|
|
assert.strictEqual(result, undefined);
|
|
});
|
|
|
|
(isWindows ? test.skip : test)('auto-approves when a symlink stays inside the working directory', async () => {
|
|
mkdirSync(join(workDir, 'real'));
|
|
symlinkSync(join(workDir, 'real'), join(workDir, 'link-in'), 'dir');
|
|
const result = await permissions.getAutoApproval(writeEvent(join(workDir, 'link-in', 'note.txt')), sessionUri);
|
|
assert.strictEqual(result, ToolCallConfirmationReason.NotNeeded);
|
|
});
|
|
|
|
test('requires confirmation for home-directory dotfiles', async () => {
|
|
const homeSession = URI.from({ scheme: 'copilot', path: '/home' }).toString();
|
|
manager.createSession(makeSummary(homeSession, URI.file(homedir()).toString()));
|
|
const result = await permissions.getAutoApproval(writeEvent(join(homedir(), '.sesperm-config-xyz')), homeSession);
|
|
assert.strictEqual(result, undefined);
|
|
});
|
|
|
|
test('auto-approves any write when session bypass is enabled', async () => {
|
|
manager.setSessionConfig(sessionUri, {
|
|
schema: platformSessionSchema.toProtocol(),
|
|
values: { [SessionConfigKey.AutoApprove]: 'autoApprove' },
|
|
});
|
|
const result = await permissions.getAutoApproval(writeEvent(join(outsideDir, 'anything.txt')), sessionUri);
|
|
assert.strictEqual(result, ToolCallConfirmationReason.Setting);
|
|
});
|
|
|
|
test('auto-approves reads inside but requires confirmation outside the working directory', async () => {
|
|
const inside = await permissions.getAutoApproval(
|
|
{ toolCallId: 'r', session: URI.parse(sessionUri), permissionKind: 'read', permissionPath: join(workDir, 'a.txt') },
|
|
sessionUri,
|
|
);
|
|
const outside = await permissions.getAutoApproval(
|
|
{ toolCallId: 'r', session: URI.parse(sessionUri), permissionKind: 'read', permissionPath: join(outsideDir, 'a.txt') },
|
|
sessionUri,
|
|
);
|
|
assert.deepStrictEqual([inside, outside], [ToolCallConfirmationReason.NotNeeded, undefined]);
|
|
});
|
|
});
|