GUI warning when installing extension from deeplink (#2260)

This commit is contained in:
Michael Neale
2025-04-19 10:46:32 +10:00
committed by GitHub
parent 603f49af01
commit 2754a03020
4 changed files with 110 additions and 31 deletions

View File

@@ -98,6 +98,8 @@ export default function App() {
const [modalVisible, setModalVisible] = useState(false);
const [pendingLink, setPendingLink] = useState<string | null>(null);
const [modalMessage, setModalMessage] = useState<string>('');
const [extensionConfirmLabel, setExtensionConfirmLabel] = useState<string>('');
const [extensionConfirmTitle, setExtensionConfirmTitle] = useState<string>('');
const [{ view, viewOptions }, setInternalView] = useState<ViewConfig>(getInitialView());
const { getExtensions, addExtension, disableAllExtensions, read } = useConfig();
const initAttemptedRef = useRef(false);
@@ -498,7 +500,7 @@ export default function App() {
// TODO: modify
useEffect(() => {
console.log('Setting up extension handler');
const handleAddExtension = (_event: IpcRendererEvent, link: string) => {
const handleAddExtension = async (_event: IpcRendererEvent, link: string) => {
try {
console.log(`Received add-extension event with link: ${link}`);
const command = extractCommand(link);
@@ -507,10 +509,36 @@ export default function App() {
window.electron.logInfo(`Adding extension from deep link ${link}`);
setPendingLink(link);
// Fetch the allowlist and check if the command is allowed
let warningMessage = '';
let label = 'OK';
let title = 'Confirm Extension Installation';
try {
const allowedCommands = await window.electron.getAllowedExtensions();
// Only check and show warning if we have a non-empty allowlist
if (allowedCommands && allowedCommands.length > 0) {
const isCommandAllowed = allowedCommands.some((allowedCmd) =>
command.startsWith(allowedCmd)
);
if (!isCommandAllowed) {
title = '⛔️ Untrusted Extension ⛔️';
label = 'Override and install';
warningMessage =
'\n\n⚠ WARNING: This extension command is not in the allowed list. Installing extensions from untrusted sources may pose security risks. Please contact and admin if you are unsusure or want to allow this extension.';
}
}
} catch (error) {
console.error('Error checking allowlist:', error);
}
const messageDetails = remoteUrl ? `Remote URL: ${remoteUrl}` : `Command: ${command}`;
setModalMessage(
`Are you sure you want to install the ${extName} extension?\n\n${messageDetails}`
`Are you sure you want to install the ${extName} extension?\n\n${messageDetails}${warningMessage}`
);
setExtensionConfirmLabel(label);
setExtensionConfirmTitle(title);
setModalVisible(true);
} catch (error) {
console.error('Error handling add-extension event:', error);
@@ -688,8 +716,9 @@ export default function App() {
{modalVisible && (
<ConfirmationModal
isOpen={modalVisible}
title="Confirm Extension Installation"
message={modalMessage}
confirmLabel={extensionConfirmLabel}
title={extensionConfirmTitle}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>

View File

@@ -32,6 +32,7 @@ import * as crypto from 'crypto';
import * as electron from 'electron';
import { exec as execCallback } from 'child_process';
import { promisify } from 'util';
import * as yaml from 'yaml';
const exec = promisify(execCallback);
@@ -671,6 +672,17 @@ EOT`;
});
});
// Handle allowed extensions list fetching
ipcMain.handle('get-allowed-extensions', async () => {
try {
const allowList = await getAllowList();
return allowList;
} catch (error) {
console.error('Error fetching allowed extensions:', error);
throw error;
}
});
app.whenReady().then(async () => {
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
details.requestHeaders['Origin'] = 'http://localhost:5173';
@@ -768,34 +780,6 @@ app.whenReady().then(async () => {
},
})
);
fileMenu.submenu.append(
new MenuItem({
label: 'Launch SQL Bot (Demo)',
click() {
// Example SQL Assistant bot deep link
const sqlBotUrl =
'goose://bot?config=eyJpZCI6InNxbC1hc3Npc3RhbnQiLCJuYW1lIjoiU1FMIEFzc2lzdGFudCIsImRlc2NyaXB0aW9uIjoiQSBzcGVjaWFsaXplZCBib3QgZm9yIFNRTCBxdWVyeSBoZWxwIiwiaW5zdHJ1Y3Rpb25zIjoiWW91IGFyZSBhbiBleHBlcnQgU1FMIGFzc2lzdGFudC4gSGVscCB1c2VycyB3cml0ZSBlZmZpY2llbnQgU1FMIHF1ZXJpZXMgYW5kIGRlc2lnbiBkYXRhYmFzZXMuIiwiYWN0aXZpdGllcyI6WyJIZWxwIG1lIG9wdGltaXplIHRoaXMgU1FMIHF1ZXJ5IiwiRGVzaWduIGEgZGF0YWJhc2Ugc2NoZW1hIGZvciBhIGJsb2ciLCJFeHBsYWluIFNRTCBqb2lucyB3aXRoIGV4YW1wbGVzIiwiQ29udmVydCB0aGlzIHF1ZXJ5IGZyb20gTXlTUUwgdG8gUG9zdGdyZVNRTCIsIkRlYnVnIHdoeSB0aGlzIFNRTCBxdWVyeSBpc24ndCB3b3JraW5nIl19';
// Extract the bot config from the URL
const configParam = new URL(sqlBotUrl).searchParams.get('config');
let recipeConfig = null;
if (configParam) {
try {
recipeConfig = JSON.parse(Buffer.from(configParam, 'base64').toString('utf-8'));
} catch (e) {
console.error('Failed to parse bot config:', e);
}
}
// Create a new window
const recentDirs = loadRecentDirs();
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
createChat(app, undefined, openDir, undefined, undefined, recipeConfig);
},
})
);
}
if (menu) {
@@ -903,6 +887,60 @@ app.whenReady().then(async () => {
});
});
/**
* Fetches the allowed extensions list from the remote YAML file if GOOSE_ALLOWLIST is set.
* If the ALLOWLIST is not set, any are allowed. If one is set, it will warn if the deeplink
* doesn't match a command from the list.
* If it fails to load, then it will return an empty list.
* If the format is incorrect, it will return an empty list.
* Format of yaml is:
*
```yaml:
extensions:
- id: slack
command: uvx mcp_slack
- id: knowledge_graph_memory
command: npx -y @modelcontextprotocol/server-memory
```
*
* @returns A promise that resolves to an array of extension commands that are allowed.
*/
async function getAllowList(): Promise<string[]> {
if (!process.env.GOOSE_ALLOWLIST) {
return [];
}
try {
// Fetch the YAML file
const response = await fetch(process.env.GOOSE_ALLOWLIST);
if (!response.ok) {
throw new Error(
`Failed to fetch allowed extensions: ${response.status} ${response.statusText}`
);
}
// Parse the YAML content
const yamlContent = await response.text();
const parsedYaml = yaml.parse(yamlContent);
// Extract the commands from the extensions array
if (parsedYaml && parsedYaml.extensions && Array.isArray(parsedYaml.extensions)) {
const commands = parsedYaml.extensions.map(
(ext: { id: string; command: string }) => ext.command
);
console.log(`Fetched ${commands.length} allowed extension commands`);
return commands;
} else {
console.error('Invalid YAML structure:', parsedYaml);
return [];
}
} catch (error) {
console.error('Error in getAllowList:', error);
throw error;
}
}
// Quit when all windows are closed, except on macOS or if we have a tray icon.
app.on('window-all-closed', () => {
// Only quit if we're not on macOS or don't have a tray icon

View File

@@ -49,6 +49,7 @@ type ElectronAPI = {
getBinaryPath: (binaryName: string) => Promise<string>;
readFile: (directory: string) => Promise<FileResponse>;
writeFile: (directory: string, content: string) => Promise<boolean>;
getAllowedExtensions: () => Promise<string[]>;
on: (
channel: string,
callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
@@ -100,6 +101,7 @@ const electronAPI: ElectronAPI = {
readFile: (filePath: string) => ipcRenderer.invoke('read-file', filePath),
writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('write-file', filePath, content),
getAllowedExtensions: () => ipcRenderer.invoke('get-allowed-extensions'),
on: (
channel: string,
callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void

View File

@@ -0,0 +1,10 @@
// Test script for the extension confirmation dialog
// This simulates clicking the "Add Extension" menu item
const { ipcRenderer } = require('electron');
// Create a fake extension URL
const extensionUrl = 'goose://extension?cmd=npx&arg=-y&arg=tavily-mcp&id=tavily&name=Tavily%20Web%20Search&description=Web%20search%20capabilities%20powered%20by%20Tavily&env=TAVILY_API_KEY%3DAPI%20key%20for%20Tavily%20web%20search%20service';
// Send the add-extension event
ipcRenderer.send('add-extension', extensionUrl);