diff options
Diffstat (limited to 'js/baba-yaga/src/core/js-bridge.js')
-rw-r--r-- | js/baba-yaga/src/core/js-bridge.js | 507 |
1 files changed, 507 insertions, 0 deletions
diff --git a/js/baba-yaga/src/core/js-bridge.js b/js/baba-yaga/src/core/js-bridge.js new file mode 100644 index 0000000..92a9972 --- /dev/null +++ b/js/baba-yaga/src/core/js-bridge.js @@ -0,0 +1,507 @@ +// js-bridge.js - Safe JavaScript interop bridge for Baba Yaga + +/** + * JavaScript Bridge for safe interop between Baba Yaga and JavaScript + * Provides sandboxed execution with configurable security controls + */ +export class BabaYagaJSBridge { + constructor(config = {}) { + this.config = { + allowedGlobals: new Set(config.allowedGlobals || [ + 'console', 'JSON', 'Math', 'Date', 'performance' + ]), + allowedFunctions: new Set(config.allowedFunctions || [ + 'JSON.parse', 'JSON.stringify', + 'Math.abs', 'Math.floor', 'Math.ceil', 'Math.round', + 'Math.min', 'Math.max', 'Math.random', + 'console.log', 'console.warn', 'console.error', + 'Date.now', 'performance.now' + ]), + maxExecutionTime: config.maxExecutionTime || 5000, + maxMemoryUsage: config.maxMemoryUsage || 50_000_000, + enableAsyncOps: config.enableAsyncOps !== false, + enableFileSystem: config.enableFileSystem || false, + enableNetwork: config.enableNetwork || false + }; + + // Create sandbox after config is set up + this.config.sandbox = config.sandbox || this.createDefaultSandbox(); + + this.lastError = null; + this.stats = { + totalCalls: 0, + successfulCalls: 0, + errorCalls: 0, + totalExecutionTime: 0 + }; + } + + createDefaultSandbox() { + const sandbox = { + // Safe globals + console: { + log: console.log.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + time: console.time.bind(console), + timeEnd: console.timeEnd.bind(console) + }, + JSON: { + parse: JSON.parse.bind(JSON), + stringify: JSON.stringify.bind(JSON) + }, + Math: Math, + Date: { + now: Date.now.bind(Date) + }, + performance: typeof performance !== 'undefined' ? { + now: performance.now.bind(performance), + mark: performance.mark?.bind(performance), + measure: performance.measure?.bind(performance) + } : undefined + }; + + // Add conditional globals based on environment + if (typeof fetch !== 'undefined' && this.config.enableNetwork) { + sandbox.fetch = fetch; + } + + if (typeof require !== 'undefined' && this.config.enableFileSystem) { + sandbox.fs = require('fs'); + sandbox.path = require('path'); + } + + // Add global functions that are in the allowed list (for testing) + if (typeof global !== 'undefined') { + for (const functionName of this.config.allowedFunctions) { + if (functionName.indexOf('.') === -1 && typeof global[functionName] === 'function') { + sandbox[functionName] = global[functionName]; + } + } + } + + return sandbox; + } + + /** + * Call a JavaScript function safely with error handling + */ + callFunction(functionName, args = []) { + const startTime = performance.now(); + this.stats.totalCalls++; + + try { + if (!this.config.allowedFunctions.has(functionName)) { + throw new Error(`Function ${functionName} is not allowed`); + } + + const fn = this.resolveFunction(functionName); + if (!fn) { + throw new Error(`Function ${functionName} not found`); + } + + // Execute with timeout protection + const result = this.executeWithTimeout(() => { + return fn.apply(this.config.sandbox, args); + }, this.config.maxExecutionTime); + + const sanitized = this.sanitizeResult(result); + + this.stats.successfulCalls++; + this.stats.totalExecutionTime += performance.now() - startTime; + + return { type: 'success', value: sanitized }; + + } catch (error) { + this.lastError = error; + this.stats.errorCalls++; + + return { + type: 'error', + error: error.message, + errorType: error.constructor.name, + stack: error.stack + }; + } + } + + /** + * Call a JavaScript function asynchronously + */ + async callFunctionAsync(functionName, args = []) { + if (!this.config.enableAsyncOps) { + return { + type: 'error', + error: 'Async operations are disabled' + }; + } + + const startTime = performance.now(); + this.stats.totalCalls++; + + try { + if (!this.config.allowedFunctions.has(functionName)) { + throw new Error(`Function ${functionName} is not allowed`); + } + + const fn = this.resolveFunction(functionName); + if (!fn) { + throw new Error(`Function ${functionName} not found`); + } + + // Execute async with timeout protection + const result = await this.executeAsyncWithTimeout(() => { + return fn.apply(this.config.sandbox, args); + }, this.config.maxExecutionTime); + + const sanitized = this.sanitizeResult(result); + + this.stats.successfulCalls++; + this.stats.totalExecutionTime += performance.now() - startTime; + + return { type: 'success', value: sanitized }; + + } catch (error) { + this.lastError = error; + this.stats.errorCalls++; + + return { + type: 'error', + error: error.message, + errorType: error.constructor.name, + stack: error.stack + }; + } + } + + /** + * Resolve a function from the sandbox by dot-notation path + */ + resolveFunction(functionName) { + const parts = functionName.split('.'); + let current = this.config.sandbox; + + for (const part of parts) { + if (!current || typeof current !== 'object') { + return null; + } + current = current[part]; + } + + return typeof current === 'function' ? current : null; + } + + /** + * Execute function with timeout protection + */ + executeWithTimeout(fn, timeout) { + // For sync operations, we can't truly timeout in JS + // This is a placeholder for potential future timeout implementation + return fn(); + } + + /** + * Execute async function with timeout protection + */ + async executeAsyncWithTimeout(fn, timeout) { + return Promise.race([ + fn(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Operation timed out')), timeout) + ) + ]); + } + + /** + * Sanitize JavaScript results for Baba Yaga consumption + */ + sanitizeResult(value) { + if (value === null || value === undefined) { + return null; // Will be converted to Err by Baba Yaga + } + + if (typeof value === 'function') { + return '[Function]'; // Don't leak functions + } + + if (value instanceof Error) { + return { + error: value.message, + errorType: value.constructor.name, + stack: value.stack + }; + } + + if (value instanceof Promise) { + return '[Promise]'; // Don't leak promises + } + + if (typeof value === 'object' && value !== null) { + if (Array.isArray(value)) { + return value.map(item => this.sanitizeResult(item)); + } + + // Sanitize object properties + const sanitized = {}; + for (const [key, val] of Object.entries(value)) { + if (typeof val !== 'function') { + sanitized[key] = this.sanitizeResult(val); + } + } + return sanitized; + } + + return value; + } + + /** + * Get property from JavaScript object safely + */ + getProperty(obj, propName) { + try { + if (obj === null || obj === undefined) { + return { type: 'error', error: 'Cannot get property of null/undefined' }; + } + + if (typeof obj !== 'object') { + return { type: 'error', error: 'Cannot get property of non-object' }; + } + + const value = obj[propName]; + const sanitized = this.sanitizeResult(value); + + return { type: 'success', value: sanitized }; + + } catch (error) { + return { type: 'error', error: error.message }; + } + } + + /** + * Set property on JavaScript object safely + */ + setProperty(obj, propName, value) { + try { + if (obj === null || obj === undefined) { + return { type: 'error', error: 'Cannot set property of null/undefined' }; + } + + if (typeof obj !== 'object') { + return { type: 'error', error: 'Cannot set property of non-object' }; + } + + obj[propName] = value; + + return { type: 'success', value: obj }; + + } catch (error) { + return { type: 'error', error: error.message }; + } + } + + /** + * Check if property exists on JavaScript object + */ + hasProperty(obj, propName) { + try { + if (obj === null || obj === undefined) { + return false; + } + + return propName in obj; + + } catch (error) { + return false; + } + } + + /** + * Convert JavaScript array to list safely + */ + jsArrayToList(jsArray) { + try { + if (!Array.isArray(jsArray)) { + return { type: 'error', error: 'Value is not an array' }; + } + + const sanitized = jsArray.map(item => this.sanitizeResult(item)); + return { type: 'success', value: sanitized }; + + } catch (error) { + return { type: 'error', error: error.message }; + } + } + + /** + * Convert Baba Yaga list to JavaScript array + */ + listToJSArray(babaList) { + try { + if (!Array.isArray(babaList)) { + return { type: 'error', error: 'Value is not a list' }; + } + + return { type: 'success', value: [...babaList] }; + + } catch (error) { + return { type: 'error', error: error.message }; + } + } + + /** + * Convert Baba Yaga table to JavaScript object + */ + tableToObject(babaTable) { + try { + if (!babaTable || babaTable.type !== 'Object' || !babaTable.properties) { + return { type: 'error', error: 'Value is not a Baba Yaga table' }; + } + + const obj = {}; + for (const [key, value] of babaTable.properties.entries()) { + obj[key] = this.convertBabaValueToJS(value); + } + + return { type: 'success', value: obj }; + + } catch (error) { + return { type: 'error', error: error.message }; + } + } + + /** + * Convert JavaScript object to Baba Yaga table + */ + objectToTable(jsObj) { + try { + if (typeof jsObj !== 'object' || jsObj === null || Array.isArray(jsObj)) { + return { type: 'error', error: 'Value is not a JavaScript object' }; + } + + const properties = new Map(); + for (const [key, value] of Object.entries(jsObj)) { + properties.set(key, this.convertJSValueToBaba(value)); + } + + return { + type: 'success', + value: { + type: 'Object', + properties + } + }; + + } catch (error) { + return { type: 'error', error: error.message }; + } + } + + /** + * Convert Baba Yaga value to JavaScript value + */ + convertBabaValueToJS(babaValue) { + if (babaValue && typeof babaValue.value === 'number') { + return babaValue.value; + } + + if (Array.isArray(babaValue)) { + return babaValue.map(item => this.convertBabaValueToJS(item)); + } + + if (babaValue && babaValue.type === 'Object' && babaValue.properties instanceof Map) { + const obj = {}; + for (const [key, value] of babaValue.properties.entries()) { + obj[key] = this.convertBabaValueToJS(value); + } + return obj; + } + + // Handle JSValue objects from io.callJS + if (babaValue && babaValue.type === 'JSValue') { + return babaValue.value; + } + + return babaValue; + } + + /** + * Convert JavaScript value to Baba Yaga value + */ + convertJSValueToBaba(jsValue) { + if (typeof jsValue === 'number') { + return { value: jsValue, isFloat: !Number.isInteger(jsValue) }; + } + + if (Array.isArray(jsValue)) { + return jsValue.map(item => this.convertJSValueToBaba(item)); + } + + if (typeof jsValue === 'object' && jsValue !== null) { + const properties = new Map(); + for (const [key, value] of Object.entries(jsValue)) { + properties.set(key, this.convertJSValueToBaba(value)); + } + return { + type: 'Object', + properties + }; + } + + return jsValue; + } + + /** + * Get bridge statistics + */ + getStats() { + const successRate = this.stats.totalCalls > 0 + ? this.stats.successfulCalls / this.stats.totalCalls + : 0; + const averageTime = this.stats.successfulCalls > 0 + ? this.stats.totalExecutionTime / this.stats.successfulCalls + : 0; + + return { + ...this.stats, + successRate, + averageTime + }; + } + + /** + * Get last JavaScript error + */ + getLastError() { + return this.lastError ? { + message: this.lastError.message, + type: this.lastError.constructor.name, + stack: this.lastError.stack + } : null; + } + + /** + * Clear last JavaScript error + */ + clearLastError() { + this.lastError = null; + } + + /** + * Reset statistics + */ + resetStats() { + this.stats = { + totalCalls: 0, + successfulCalls: 0, + errorCalls: 0, + totalExecutionTime: 0 + }; + } +} + +/** + * Create a default JS bridge instance + */ +export function createDefaultJSBridge(config = {}) { + return new BabaYagaJSBridge(config); +} |