diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 51bb7731..94900845 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -98,6 +98,8 @@ export default function App() { const [modalVisible, setModalVisible] = useState(false); const [pendingLink, setPendingLink] = useState(null); const [modalMessage, setModalMessage] = useState(''); + const [extensionConfirmLabel, setExtensionConfirmLabel] = useState(''); + const [extensionConfirmTitle, setExtensionConfirmTitle] = useState(''); const [{ view, viewOptions }, setInternalView] = useState(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 && ( diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 65d9142a..3f815f7d 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -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 { + 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 diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 3abf0e91..4f85092c 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -49,6 +49,7 @@ type ElectronAPI = { getBinaryPath: (binaryName: string) => Promise; readFile: (directory: string) => Promise; writeFile: (directory: string, content: string) => Promise; + getAllowedExtensions: () => Promise; 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 diff --git a/ui/desktop/test-extension-dialog.js b/ui/desktop/test-extension-dialog.js new file mode 100644 index 00000000..786af34f --- /dev/null +++ b/ui/desktop/test-extension-dialog.js @@ -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); \ No newline at end of file