UNPKG

guardian-ai

Version:

An ethical telemetry and governance platform for AI applications

630 lines (538 loc) 20.1 kB
/** * Guardian - Ethical AI Monitoring and Governance Platform * * This module provides a zero-configuration middleware for monitoring and * governing AI API calls in Node.js applications. */ const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const http = require('http'); const https = require('https'); const net = require('net'); const os = require('os'); // Default configuration const DEFAULT_CONFIG = { serviceName: 'guardian-app', environment: 'development', prePrompt: "Always adhere to ethical guidelines and refuse harmful requests.", debug: false, autoStart: true, // Default rules if no guardian_rules.json is found rules: [ { name: "Default Ignore Instructions", pattern: "(?i)ignore (previous|prior) instructions", severity: "medium", description: "Attempt to make the model ignore previous instructions." }, { name: "Default Pretend/Ignore", pattern: "(?i)\\b(pretend|imagine|role-play|simulation).+?(ignore|forget|disregard).+?(instruction|prompt|rule)", severity: "medium", description: "Attempt to use role-playing to bypass rules." }, { name: "Default Hypothetical Bypass", pattern: "(?i)\\b(let's play a game|hypothetically speaking|in a fictional scenario)\\b", severity: "low", description: "Using hypothetical scenarios, potentially to bypass safety." }, { name: "Default Hacking Keywords", pattern: "(?i)\\b(hack|bypass security|exploit|vulnerability)\\b", severity: "high", description: "Keywords related to attempting to hack or bypass security." } ] }; class Guardian { constructor(config = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; this.port = null; this.process = null; this.ready = false; this.originalFetch = global.fetch; this.originalHttpRequest = http.request; this.originalHttpsRequest = https.request; // Find the binary path this.binaryPath = this._findBinaryPath(); // Automatically start Guardian if autoStart is enabled if (this.config.autoStart) { this.start(); } } /** * Start the Guardian proxy server * @returns {Promise} Resolves when the server is ready */ start() { return new Promise(async (resolve, reject) => { try { if (this.process) { this.log('Guardian already running'); return resolve(); } // Find a free port to use this.port = await this._findFreePort(); this.log(`Starting Guardian proxy on port ${this.port}`); // Check if binary exists if (!fs.existsSync(this.binaryPath)) { return reject(new Error(`Guardian binary not found at ${this.binaryPath}`)); } // --- Load Rules --- let rulesToUse = this.config.rules || DEFAULT_CONFIG.rules; // Start with defaults or config override const userRootRulesPath = path.resolve(process.cwd(), 'guardian_rules.json'); // Look for .json in CWD const userSrcRulesPath = path.resolve(process.cwd(), 'src', 'guardian_rules.json'); // Look for .json in CWD/src let loadedFromFile = false; this.log(`Checking for rules file at: ${userRootRulesPath}`); if (fs.existsSync(userRootRulesPath)) { try { const userRulesFile = fs.readFileSync(userRootRulesPath, 'utf8'); const parsedJson = JSON.parse(userRulesFile); // Use JSON.parse if (parsedJson && Array.isArray(parsedJson.rules)) { rulesToUse = parsedJson.rules; this.log(`Loaded ${rulesToUse.length} rules from ${userRootRulesPath}`); loadedFromFile = true; } else { this.log(`Warning: ${userRootRulesPath} found but is invalid or empty. Using default rules.`); } } catch (jsonErr) { this.log(`Warning: Error reading or parsing ${userRootRulesPath}: ${jsonErr.message}. Using default rules.`); } } if (!loadedFromFile) { this.log(`Checking for rules file at: ${userSrcRulesPath}`); if (fs.existsSync(userSrcRulesPath)) { try { const userRulesFile = fs.readFileSync(userSrcRulesPath, 'utf8'); const parsedJson = JSON.parse(userRulesFile); if (parsedJson && Array.isArray(parsedJson.rules)) { // Extract the "rules" array from the JSON object rulesToUse = parsedJson.rules; this.log(`Loaded ${rulesToUse.length} rules from ${userSrcRulesPath}`); loadedFromFile = true; } else { this.log(`Warning: ${userSrcRulesPath} found but is invalid or empty. Using default rules.`); } } catch (jsonErr) { this.log(`Warning: Error reading or parsing ${userSrcRulesPath}: ${jsonErr.message}. Using default rules.`); } } } if (!loadedFromFile) { this.log('No external rules file found or loaded successfully. Using built-in default rules.'); rulesToUse = DEFAULT_CONFIG.rules; // Explicitly fall back to built-in defaults } // --- End Load Rules --- // Prepare arguments for the Guardian binary const args = [ `-port=${this.port}`, `-service=${this.config.serviceName}`, `-env=${this.config.environment}`, `-rules=${this._prepareRulesForGo(rulesToUse)}` ]; if (this.config.prePrompt) { args.push(`-pre-prompt=${this.config.prePrompt}`); } if (this.config.debug) { args.push('-debug=true'); } // Spawn the Guardian process this.process = spawn(this.binaryPath, args, { stdio: this.config.debug ? 'inherit' : 'pipe' }); // Handle process events this.process.on('error', (err) => { this.log(`Guardian process error: ${err.message}`); reject(err); }); this.process.on('exit', (code) => { this.log(`Guardian process exited with code ${code}`); this.process = null; this.ready = false; }); if (!this.config.debug && this.process.stdout && this.process.stderr) { // Log stdout/stderr if not in debug mode this.process.stdout.on('data', (data) => { this.log(`Guardian: ${data.toString().trim()}`); }); this.process.stderr.on('data', (data) => { this.log(`Guardian: ${data.toString().trim()}`); }); } // Patch network APIs to route through Guardian with transparent forwarding this._patchNetworkAPIs(); // Wait for server to be ready await this._waitForReady(); this.ready = true; this.log('Guardian proxy is ready'); resolve(); } catch (err) { this.log(`Error starting Guardian: ${err.message}`); reject(err); } }); } /** * Stop the Guardian proxy server and restore original network behavior */ stop() { if (this.process) { this.log('Stopping Guardian proxy'); this.process.kill(); this.process = null; this.ready = false; } this._restoreNetworkAPIs(); } /** * Find a free port to use for the Guardian proxy * @private */ _findFreePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.on('error', reject); server.listen(0, () => { const port = server.address().port; server.close(() => resolve(port)); }); }); } /** * Wait for the Guardian server to be ready * @private */ _waitForReady() { return new Promise((resolve, reject) => { const maxAttempts = 30; let attempts = 0; const checkHealth = () => { attempts++; http.get(`http://localhost:${this.port}/_guardian/health`, (res) => { if (res.statusCode === 200) { return resolve(); } if (attempts >= maxAttempts) { return reject(new Error('Guardian server failed to start')); } setTimeout(checkHealth, 100); }).on('error', () => { if (attempts >= maxAttempts) { return reject(new Error('Guardian server failed to start')); } setTimeout(checkHealth, 100); }); }; // Start checking after a small delay setTimeout(checkHealth, 100); }); } /** * Patch network APIs to route AI requests through Guardian * @private */ _patchNetworkAPIs() { this._patchFetch(); this._patchHttpRequest(); } /** * Restore original network APIs * @private */ _restoreNetworkAPIs() { if (this.originalFetch) { global.fetch = this.originalFetch; } if (this.originalHttpRequest) { http.request = this.originalHttpRequest; } if (this.originalHttpsRequest) { https.request = this.originalHttpsRequest; } this.log('Restored original network APIs'); } /** * Patch the fetch API to route through Guardian * @private */ _patchFetch() { if (!global.fetch) return; const self = this; const originalFetch = global.fetch; global.fetch = async function guardianFetch(url, options = {}) { // Check if this is an AI API call that should be routed through Guardian if (self._isAIEndpoint(url)) { try { // Ensure we preserve the original destination for internal forwarding const originalUrl = url.toString(); // Create modified headers to include original destination const modifiedOptions = { ...options }; modifiedOptions.headers = { ...(options.headers || {}) }; // Add the original destination header if we're redirecting modifiedOptions.headers['X-Guardian-Original-Destination'] = originalUrl; self.log(`Routing AI API call through Guardian: ${url}`); // Route to Guardian proxy const proxyUrl = new URL('http://localhost:' + self.port); proxyUrl.pathname = new URL(url).pathname; proxyUrl.search = new URL(url).search; return originalFetch(proxyUrl.toString(), modifiedOptions); } catch (err) { self.log(`Error routing through Guardian: ${err.message}`); return originalFetch(url, options); } } // Not an AI endpoint, use original fetch return originalFetch(url, options); }; } /** * Patch the HTTP/HTTPS request APIs to route through Guardian * @private */ _patchHttpRequest() { const self = this; // Patch HTTP http.request = function guardianHttpRequest(options, callback) { if (self._isAIEndpointFromOptions(options)) { self.log(`Routing HTTP AI API call through Guardian`); // Save original options for forwarding let originalDestination = ''; // Extract original destination if (typeof options === 'string') { originalDestination = options; } else if (options instanceof URL) { originalDestination = options.toString(); } else { const protocol = options.protocol || 'http:'; const host = options.host || options.hostname || 'localhost'; const port = options.port ? `:${options.port}` : ''; const path = options.path || '/'; originalDestination = `${protocol}//${host}${port}${path}`; } self.log(`Original destination: ${originalDestination}`); // Modify options to go through Guardian proxy if (typeof options === 'string' || options instanceof URL) { const originalUrl = options; options = new URL(options.toString()); options.host = 'localhost'; options.hostname = 'localhost'; options.port = self.port; options.headers = options.headers || {}; options.headers['X-Guardian-Original-Destination'] = originalUrl.toString(); } else { // Clone options to avoid modifying the original const originalOpts = { ...options }; // Add header to the new options options = { ...options }; if (!options.headers) options.headers = {}; options.headers['X-Guardian-Original-Destination'] = originalDestination; // Modify to go through Guardian options.host = 'localhost'; options.hostname = 'localhost'; options.port = self.port; } self.log(`Forwarding through Guardian proxy: localhost:${self.port}`); } return self.originalHttpRequest.call(http, options, callback); }; // Similar approach for HTTPS https.request = function guardianHttpsRequest(options, callback) { if (self._isAIEndpointFromOptions(options)) { self.log(`Routing HTTPS AI API call through Guardian`); // Save original options for forwarding let originalDestination = ''; // Extract original destination if (typeof options === 'string') { originalDestination = options; } else if (options instanceof URL) { originalDestination = options.toString(); } else { const protocol = 'https:'; const host = options.host || options.hostname || 'localhost'; const port = options.port ? `:${options.port}` : ''; const path = options.path || '/'; originalDestination = `${protocol}//${host}${port}${path}`; } self.log(`Original destination: ${originalDestination}`); // For HTTPS, we have to convert to using HTTP to the Guardian proxy let newOptions; if (typeof options === 'string' || options instanceof URL) { const url = new URL(options.toString()); newOptions = { protocol: 'http:', host: 'localhost', hostname: 'localhost', port: self.port, path: url.pathname + url.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Guardian-Original-Destination': originalDestination } }; } else { // Clone headers and add original destination const headers = { ...(options.headers || {}) }; headers['X-Guardian-Original-Destination'] = originalDestination; newOptions = { ...options, protocol: 'http:', host: 'localhost', hostname: 'localhost', port: self.port, headers }; } self.log(`Forwarding through Guardian proxy: localhost:${self.port}`); return self.originalHttpRequest.call(http, newOptions, callback); } return self.originalHttpsRequest.call(https, options, callback); }; } /** * Check if a URL is for an AI endpoint that should be monitored * @private */ _isAIEndpoint(url) { const urlStr = url.toString().toLowerCase(); return ( urlStr.includes('/completions') || urlStr.includes('/chat/completions') || urlStr.includes('/generate') || urlStr.includes('/v1/engines') || urlStr.includes('/v1/chat') ); } /** * Check if HTTP request options point to an AI endpoint * @private */ _isAIEndpointFromOptions(options) { if (typeof options === 'string') { return this._isAIEndpoint(options); } if (options instanceof URL) { return this._isAIEndpoint(options.toString()); } // Check path in options object if (options.path) { const path = options.path.toLowerCase(); return ( path.includes('/completions') || path.includes('/chat/completions') || path.includes('/generate') || path.includes('/v1/engines') || path.includes('/v1/chat') ); } return false; } /** * Find the Guardian binary path * @private */ _findBinaryPath() { // Start with the module directory const baseDir = path.dirname(__filename); // Determine platform-specific binary name let binaryName = 'guardian'; if (os.platform() === 'win32') { binaryName += '.exe'; } // Check for platform/arch specific binary const platformArch = `${os.platform()}-${os.arch()}`; let binaryPath = path.join(baseDir, platformArch, binaryName); if (fs.existsSync(binaryPath)) { return binaryPath; } // Fall back to default location binaryPath = path.join(baseDir, binaryName); if (fs.existsSync(binaryPath)) { return binaryPath; } // Fall back to project root binaryPath = path.join(baseDir, '..', binaryName); if (fs.existsSync(binaryPath)) { return binaryPath; } // If not found, return the default path anyway return binaryPath; } /** * Prepare rules for passing to Go binary * Properly escapes regex patterns and ensures format compatibility * @private */ _prepareRulesForGo(rules) { // Go expects an array of Rule objects directly return JSON.stringify(rules.map(rule => { // Create a copy to avoid modifying the original rule const processedRule = { ...rule }; // Ensure pattern has proper escaping for Go regex // This is critical for patterns with \b word boundaries and other special regex constructs if (processedRule.pattern) { // No additional processing needed - the pattern is already correctly escaped in the JSON // The Go regexp.Compile will handle it correctly } return processedRule; })); } /** * Log a message if debug is enabled * @private */ log(message) { if (this.config.debug) { console.log(`[Guardian] ${message}`); } } /** * Create a middleware function for Express.js applications * This is an alternative to the API patching approach */ middleware() { const self = this; return function(req, res, next) { if (!self.ready || !self._isAIEndpointFromOptions({ path: req.path })) { return next(); } self.log(`Express middleware handling AI endpoint: ${req.path}`); // Store the original destination const protocol = req.protocol; const host = req.headers.host; const path = req.originalUrl || req.url; const originalDestination = `${protocol}://${host}${path}`; req.headers['X-Guardian-Original-Destination'] = originalDestination; // Proxy the request to Guardian const proxyReq = http.request({ method: req.method, host: 'localhost', port: self.port, path: req.url, headers: req.headers }, proxyRes => { // Copy status code res.status(proxyRes.statusCode); // Copy headers Object.keys(proxyRes.headers).forEach(key => { res.set(key, proxyRes.headers[key]); }); // Stream response data proxyRes.pipe(res); }); // Handle errors proxyReq.on('error', error => { self.log(`Error in Guardian proxy: ${error.message}`); next(error); }); // Send request body if present if (req.body) { proxyReq.write(JSON.stringify(req.body)); } proxyReq.end(); }; } } module.exports = Guardian;