feat: Support Extension Hooks with Security Warning (#14460)

This commit is contained in:
Abhi
2025-12-03 15:07:37 -05:00
committed by GitHub
parent 939cb67621
commit eb3312e7ba
4 changed files with 225 additions and 10 deletions

View File

@@ -112,20 +112,31 @@ describe('consent', () => {
it('should request consent if there is no previous config', async () => {
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(baseConfig, requestConsent, undefined);
await maybeRequestConsentOrFail(
baseConfig,
requestConsent,
false,
undefined,
);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should not request consent if configs are identical', async () => {
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(baseConfig, requestConsent, baseConfig);
await maybeRequestConsentOrFail(
baseConfig,
requestConsent,
false,
baseConfig,
false,
);
expect(requestConsent).not.toHaveBeenCalled();
});
it('should throw an error if consent is denied', async () => {
const requestConsent = vi.fn().mockResolvedValue(false);
await expect(
maybeRequestConsentOrFail(baseConfig, requestConsent, undefined),
maybeRequestConsentOrFail(baseConfig, requestConsent, false, undefined),
).rejects.toThrow('Installation cancelled for "test-ext".');
});
@@ -141,7 +152,12 @@ describe('consent', () => {
excludeTools: ['tool1', 'tool2'],
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(config, requestConsent, undefined);
await maybeRequestConsentOrFail(
config,
requestConsent,
false,
undefined,
);
const expectedConsentString = [
'Installing extension "test-ext".',
@@ -163,7 +179,13 @@ describe('consent', () => {
mcpServers: { server1: { command: 'npm', args: ['start'] } },
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);
await maybeRequestConsentOrFail(
newConfig,
requestConsent,
false,
prevConfig,
false,
);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
@@ -174,7 +196,13 @@ describe('consent', () => {
contextFileName: 'new-context.md',
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);
await maybeRequestConsentOrFail(
newConfig,
requestConsent,
false,
prevConfig,
false,
);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
@@ -185,7 +213,41 @@ describe('consent', () => {
excludeTools: ['new-tool'],
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);
await maybeRequestConsentOrFail(
newConfig,
requestConsent,
false,
prevConfig,
false,
);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should include warning when hooks are present', async () => {
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(
baseConfig,
requestConsent,
true,
undefined,
);
expect(requestConsent).toHaveBeenCalledWith(
expect.stringContaining(
'⚠️ This extension contains Hooks which can automatically execute commands.',
),
);
});
it('should request consent if hooks status changes', async () => {
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(
baseConfig,
requestConsent,
true,
baseConfig,
false,
);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
});

View File

@@ -103,7 +103,10 @@ async function promptForConsentInteractive(
* Builds a consent string for installing an extension based on it's
* extensionConfig.
*/
function extensionConsentString(extensionConfig: ExtensionConfig): string {
function extensionConsentString(
extensionConfig: ExtensionConfig,
hasHooks: boolean,
): string {
const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
const output: string[] = [];
const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {});
@@ -130,6 +133,11 @@ function extensionConsentString(extensionConfig: ExtensionConfig): string {
`This extension will exclude the following core tools: ${sanitizedConfig.excludeTools}`,
);
}
if (hasHooks) {
output.push(
'⚠️ This extension contains Hooks which can automatically execute commands.',
);
}
return output.join('\n');
}
@@ -145,12 +153,15 @@ function extensionConsentString(extensionConfig: ExtensionConfig): string {
export async function maybeRequestConsentOrFail(
extensionConfig: ExtensionConfig,
requestConsent: (consent: string) => Promise<boolean>,
hasHooks: boolean,
previousExtensionConfig?: ExtensionConfig,
previousHasHooks?: boolean,
) {
const extensionConsent = extensionConsentString(extensionConfig);
const extensionConsent = extensionConsentString(extensionConfig, hasHooks);
if (previousExtensionConfig) {
const previousExtensionConsent = extensionConsentString(
previousExtensionConfig,
previousHasHooks ?? false,
);
if (previousExtensionConsent === extensionConsent) {
return;