package sandbox import ( "encoding/base64" "os" "path/filepath" "strings" ) // ContainsGlobChars checks if a path pattern contains glob characters. func ContainsGlobChars(pattern string) bool { return strings.ContainsAny(pattern, "*?[]") } // RemoveTrailingGlobSuffix removes trailing /** from a path pattern. func RemoveTrailingGlobSuffix(pattern string) string { return strings.TrimSuffix(pattern, "/**") } // NormalizePath normalizes a path for sandbox configuration. // Handles tilde expansion and relative paths. func NormalizePath(pathPattern string) string { home, _ := os.UserHomeDir() cwd, _ := os.Getwd() normalized := pathPattern // Expand ~ and relative paths switch { case pathPattern == "~": normalized = home case strings.HasPrefix(pathPattern, "~/"): normalized = filepath.Join(home, pathPattern[2:]) case strings.HasPrefix(pathPattern, "./"), strings.HasPrefix(pathPattern, "../"): normalized, _ = filepath.Abs(filepath.Join(cwd, pathPattern)) case !filepath.IsAbs(pathPattern) && !ContainsGlobChars(pathPattern): normalized, _ = filepath.Abs(filepath.Join(cwd, pathPattern)) } // For non-glob patterns, try to resolve symlinks if !ContainsGlobChars(normalized) { if resolved, err := filepath.EvalSymlinks(normalized); err == nil { return resolved } } return normalized } // GenerateProxyEnvVars creates environment variables for proxy configuration. // Used on macOS where transparent proxying is not available. func GenerateProxyEnvVars(proxyURL string) []string { envVars := []string{ "GREYWALL_SANDBOX=1", "TMPDIR=/tmp/greywall", } if proxyURL == "" { return envVars } // NO_PROXY for localhost and private networks noProxy := strings.Join([]string{ "localhost", "127.0.0.1", "::1", "*.local", ".local", "169.254.0.0/16", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", }, ",") envVars = append(envVars, "NO_PROXY="+noProxy, "no_proxy="+noProxy, "ALL_PROXY="+proxyURL, "all_proxy="+proxyURL, "HTTP_PROXY="+proxyURL, "HTTPS_PROXY="+proxyURL, "http_proxy="+proxyURL, "https_proxy="+proxyURL, ) return envVars } // GenerateHTTPProxyEnvVars creates environment variables for an HTTP proxy. // Used when domain filtering is active (HTTP CONNECT proxy, not SOCKS5). func GenerateHTTPProxyEnvVars(httpProxyURL string) []string { envVars := []string{ "GREYWALL_SANDBOX=1", "TMPDIR=/tmp/greywall", } if httpProxyURL == "" { return envVars } // NO_PROXY for localhost and private networks noProxy := strings.Join([]string{ "localhost", "127.0.0.1", "::1", "*.local", ".local", "169.254.0.0/16", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", }, ",") envVars = append(envVars, "NO_PROXY="+noProxy, "no_proxy="+noProxy, "HTTP_PROXY="+httpProxyURL, "HTTPS_PROXY="+httpProxyURL, "http_proxy="+httpProxyURL, "https_proxy="+httpProxyURL, ) // Inject Node.js proxy bootstrap so fetch() honors HTTP_PROXY. // Appends to existing NODE_OPTIONS if set. nodeOpts := "--require " + nodeProxyBootstrapPath if existing := os.Getenv("NODE_OPTIONS"); existing != "" { nodeOpts = existing + " " + nodeOpts } envVars = append(envVars, "NODE_OPTIONS="+nodeOpts) return envVars } // nodeProxyBootstrapJS is the Node.js bootstrap script that makes both // fetch() and http/https.request() respect HTTP_PROXY/HTTPS_PROXY env vars. // // Two mechanisms are needed because Node.js has two separate HTTP stacks: // 1. fetch() — powered by undici, patched via EnvHttpProxyAgent // 2. http.request()/https.request() — built-in modules, patched via // Agent.prototype.createConnection on BOTH http.Agent and https.Agent. // This patches the prototype so ALL agent instances (including custom ones // created by libraries like node-fetch, axios, got, etc.) tunnel through // the proxy — not just globalAgent. // // The undici setup tries multiple strategies to find the module: // 1. require('undici') from CWD // 2. createRequire from the main script's path (finds it in the app's node_modules) const nodeProxyBootstrapJS = `'use strict'; (function() { var proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.https_proxy || process.env.http_proxy; if (!proxyUrl) return; // --- Part 1: Patch fetch() via undici EnvHttpProxyAgent --- // Strategy: set global dispatcher AND wrap globalThis.fetch to force proxy. // This prevents openclaw or other code from overriding the global dispatcher. var undiciModule = null; var proxyAgent = null; function tryGetUndici(undici) { if (undici && typeof undici.EnvHttpProxyAgent === 'function' && typeof undici.setGlobalDispatcher === 'function') { return undici; } return null; } try { undiciModule = tryGetUndici(require('undici')); } catch (e) {} if (!undiciModule) { try { var mainScript = process.argv[1]; if (mainScript) { var createRequire = require('module').createRequire; var requireFrom = createRequire(require('path').resolve(mainScript)); undiciModule = tryGetUndici(requireFrom('undici')); } } catch (e) {} } if (undiciModule) { proxyAgent = new undiciModule.EnvHttpProxyAgent(); undiciModule.setGlobalDispatcher(proxyAgent); // Wrap globalThis.fetch to force proxy dispatcher on every call. // This prevents code that overrides the global dispatcher from bypassing the proxy. if (typeof globalThis.fetch === 'function') { var _origFetch = globalThis.fetch; globalThis.fetch = function(input, init) { process.stderr.write('[greywall:node-bootstrap] fetch: ' + (typeof input === 'string' ? input : (input && input.url ? input.url : '?')) + '\n'); if (!init) init = {}; init.dispatcher = proxyAgent; return _origFetch.call(globalThis, input, init); }; } // Also wrap undici.fetch and undici.request to catch direct usage if (typeof undiciModule.fetch === 'function') { var _origUndFetch = undiciModule.fetch; undiciModule.fetch = function(input, init) { if (!init) init = {}; init.dispatcher = proxyAgent; return _origUndFetch.call(undiciModule, input, init); }; } if (typeof undiciModule.request === 'function') { var _origUndRequest = undiciModule.request; undiciModule.request = function(url, opts) { if (!opts) opts = {}; opts.dispatcher = proxyAgent; return _origUndRequest.call(undiciModule, url, opts); }; } } // --- Shared setup for Parts 2 and 3 --- var url = require('url'); var http = require('http'); var https = require('https'); var tls = require('tls'); var parsed = new url.URL(proxyUrl); var proxyHost = parsed.hostname; var proxyPort = parseInt(parsed.port, 10); var noProxyRaw = process.env.NO_PROXY || process.env.no_proxy || ''; var noProxyList = noProxyRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); function isIPAddress(h) { // IPv4 or IPv6 — skip DNS proxy for raw IPs return /^\d{1,3}(\.\d{1,3}){3}$/.test(h) || h.indexOf(':') !== -1; } function shouldProxy(hostname) { if (!hostname || isIPAddress(hostname)) return false; for (var i = 0; i < noProxyList.length; i++) { var p = noProxyList[i]; if (p === hostname) return false; if (p.charAt(0) === '.' && hostname.length > p.length && hostname.indexOf(p, hostname.length - p.length) !== -1) return false; if (p.charAt(0) === '*' && hostname.length >= p.length - 1 && hostname.indexOf(p.slice(1), hostname.length - p.length + 1) !== -1) return false; } return true; } // Save originals before patching var origHttpCreateConnection = http.Agent.prototype.createConnection; var origHttpsCreateConnection = https.Agent.prototype.createConnection; // Direct agent for CONNECT requests to the proxy itself (avoids recursion) var directAgent = new http.Agent({ keepAlive: false }); directAgent.createConnection = origHttpCreateConnection; // --- Part 2: Patch Agent.prototype.createConnection on both http and https --- // This ensures ALL agent instances tunnel through the proxy, not just globalAgent. // Libraries like node-fetch, axios, got create their own agents — patching the // prototype catches them all. try { // Patch https.Agent.prototype — affects ALL https.Agent instances https.Agent.prototype.createConnection = function(options, callback) { var targetHost = options.host || options.hostname; var targetPort = options.port || 443; if (!shouldProxy(targetHost)) { return origHttpsCreateConnection.call(this, options, callback); } var connectReq = http.request({ host: proxyHost, port: proxyPort, method: 'CONNECT', path: targetHost + ':' + targetPort, agent: directAgent, }); connectReq.on('connect', function(res, socket) { if (res.statusCode === 200) { var tlsSocket = tls.connect({ socket: socket, servername: options.servername || targetHost, rejectUnauthorized: options.rejectUnauthorized !== false, }); callback(null, tlsSocket); } else { socket.destroy(); callback(new Error('Proxy CONNECT failed: ' + res.statusCode)); } }); connectReq.on('error', function(err) { callback(err); }); connectReq.end(); }; // Patch http.Agent.prototype — affects ALL http.Agent instances http.Agent.prototype.createConnection = function(options, callback) { var targetHost = options.host || options.hostname; var targetPort = options.port || 80; if (!shouldProxy(targetHost)) { return origHttpCreateConnection.call(this, options, callback); } var connectReq = http.request({ host: proxyHost, port: proxyPort, method: 'CONNECT', path: targetHost + ':' + targetPort, agent: directAgent, }); connectReq.on('connect', function(res, socket) { if (res.statusCode === 200) { callback(null, socket); } else { socket.destroy(); callback(new Error('Proxy CONNECT failed: ' + res.statusCode)); } }); connectReq.on('error', function(err) { callback(err); }); connectReq.end(); }; } catch (e) {} // --- Part 3: Patch dns.lookup / dns.promises.lookup to resolve through proxy --- // OpenClaw (and other apps) do DNS resolution before fetch for SSRF protection. // Inside the sandbox, DNS is blocked. Route lookups through the proxy's // /__greywall_dns endpoint which resolves on the host side. try { var dns = require('dns'); var dnsPromises = require('dns/promises'); var origDnsLookup = dns.lookup; var origDnsPromisesLookup = dnsPromises.lookup; function proxyDnsResolve(hostname) { return new Promise(function(resolve, reject) { var req = http.request({ host: proxyHost, port: proxyPort, path: '/__greywall_dns?host=' + encodeURIComponent(hostname), method: 'GET', agent: directAgent, }, function(res) { var data = ''; res.on('data', function(chunk) { data += chunk; }); res.on('end', function() { try { var parsed = JSON.parse(data); if (parsed.error) { var err = new Error(parsed.error); err.code = 'ENOTFOUND'; reject(err); } else { resolve(parsed.addresses || []); } } catch(e) { reject(e); } }); }); req.on('error', reject); req.end(); }); } dnsPromises.lookup = function(hostname, options) { if (!shouldProxy(hostname)) { return origDnsPromisesLookup.call(dnsPromises, hostname, options); } return proxyDnsResolve(hostname).then(function(addresses) { if (!addresses || addresses.length === 0) { var err = new Error('getaddrinfo ENOTFOUND ' + hostname); err.code = 'ENOTFOUND'; throw err; } var opts = (typeof options === 'object' && options !== null) ? options : {}; var family = typeof options === 'number' ? options : (opts.family || 0); var filtered = addresses; if (family === 4 || family === 6) { filtered = addresses.filter(function(a) { return a.family === family; }); if (filtered.length === 0) filtered = addresses; } if (opts.all) { return filtered; } return filtered[0]; }); }; dns.lookup = function(hostname, options, callback) { if (typeof options === 'function') { callback = options; options = {}; } if (!shouldProxy(hostname)) { return origDnsLookup.call(dns, hostname, options, callback); } dnsPromises.lookup(hostname, options).then(function(result) { if (Array.isArray(result)) { callback(null, result); } else { callback(null, result.address, result.family); } }, function(err) { callback(err); }); }; } catch (e) {} })(); ` // nodeProxyBootstrapPath is the path where the bootstrap script is written. const nodeProxyBootstrapPath = "/tmp/greywall/node-proxy-bootstrap.js" // WriteNodeProxyBootstrap writes the Node.js proxy bootstrap script to disk. // Returns the path to the script, or an error if it couldn't be written. func WriteNodeProxyBootstrap() (string, error) { dir := filepath.Dir(nodeProxyBootstrapPath) if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } if err := os.WriteFile(nodeProxyBootstrapPath, []byte(nodeProxyBootstrapJS), 0o644); err != nil { return "", err } return nodeProxyBootstrapPath, nil } // EncodeSandboxedCommand encodes a command for sandbox monitoring. func EncodeSandboxedCommand(command string) string { if len(command) > 100 { command = command[:100] } return base64.StdEncoding.EncodeToString([]byte(command)) } // DecodeSandboxedCommand decodes a base64-encoded command. func DecodeSandboxedCommand(encoded string) (string, error) { data, err := base64.StdEncoding.DecodeString(encoded) if err != nil { return "", err } return string(data), nil }