UNPKG

claude-flow

Version:

Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration

880 lines (775 loc) • 21.1 kB
/** * Gas Town CLI Bridge * * Provides a secure wrapper around the `gt` (Gas Town) CLI tool. * Implements command execution with proper input sanitization, * argument validation, and error handling. * * Security Features: * - All inputs validated with Zod schemas * - No shell execution (uses execFile) * - Command allowlist enforcement * - Argument sanitization * * @module v3/plugins/gastown-bridge/bridges/gt-bridge */ import { execFile } from 'child_process'; import { promisify } from 'util'; import { z } from 'zod'; import { LRUCache, BatchDeduplicator, Lazy, } from '../cache.js'; const execFileAsync = promisify(execFile); // ============================================================================ // Performance Caches // ============================================================================ /** Result cache for memoizing expensive CLI calls */ const resultCache = new LRUCache<string, GtResult<string>>({ maxEntries: 200, ttlMs: 30 * 1000, // 30 sec TTL (gas prices change frequently) }); /** Longer cache for static data like tx status */ const staticCache = new LRUCache<string, unknown>({ maxEntries: 500, ttlMs: 5 * 60 * 1000, // 5 min TTL }); /** Deduplicator for concurrent identical CLI calls */ const execDedup = new BatchDeduplicator<GtResult<string>>(); /** Lazy parsed output cache */ const parsedCache = new LRUCache<string, unknown>({ maxEntries: 500, ttlMs: 60 * 1000, // 1 min TTL }); /** * FNV-1a hash for cache keys */ function hashArgs(args: string[]): string { let hash = 2166136261; for (const arg of args) { for (let i = 0; i < arg.length; i++) { hash ^= arg.charCodeAt(i); hash = (hash * 16777619) >>> 0; } hash ^= 0xff; // separator } return hash.toString(36); } // ============================================================================ // Zod Validation Schemas // ============================================================================ /** * Safe string pattern - no shell metacharacters */ const SafeStringSchema = z.string() .max(1024, 'String too long') .refine( (val) => !/[;&|`$(){}><\n\r\0]/.test(val), 'String contains shell metacharacters' ); /** * Safe identifier pattern - alphanumeric with underscore/hyphen */ const IdentifierSchema = z.string() .min(1, 'Identifier cannot be empty') .max(64, 'Identifier too long') .regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/, 'Invalid identifier format'); /** * Gas price schema */ const GasPriceSchema = z.number() .positive('Gas price must be positive') .max(1_000_000, 'Gas price exceeds maximum'); /** * Gas limit schema */ const GasLimitSchema = z.number() .int('Gas limit must be an integer') .positive('Gas limit must be positive') .max(30_000_000, 'Gas limit exceeds maximum'); /** * Transaction hash schema (0x prefixed hex) */ const TxHashSchema = z.string() .regex(/^0x[a-fA-F0-9]{64}$/, 'Invalid transaction hash format'); /** * Address schema (0x prefixed hex) */ const AddressSchema = z.string() .regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid address format'); /** * Network schema */ const NetworkSchema = z.enum([ 'mainnet', 'goerli', 'sepolia', 'polygon', 'arbitrum', 'optimism', 'base', 'local', ]); /** * GT command argument schema */ const GtArgumentSchema = z.string() .max(512, 'Argument too long') .refine( (val) => !val.includes('\0'), 'Argument contains null byte' ) .refine( (val) => !/[;&|`$(){}><]/.test(val), 'Argument contains shell metacharacters' ); // ============================================================================ // Types // ============================================================================ /** * Gas Town executor configuration */ export interface GtBridgeConfig { /** * Path to gt executable * Default: 'gt' (assumes in PATH) */ gtPath?: string; /** * Working directory for execution */ cwd?: string; /** * Execution timeout in milliseconds * Default: 30000 (30 seconds) */ timeout?: number; /** * Maximum buffer size for output * Default: 10MB */ maxBuffer?: number; /** * Environment variables */ env?: NodeJS.ProcessEnv; /** * Default network */ defaultNetwork?: z.infer<typeof NetworkSchema>; } /** * Gas estimation result */ export interface GasEstimate { gasLimit: number; gasPrice: string; maxFeePerGas?: string; maxPriorityFeePerGas?: string; estimatedCost: string; estimatedCostUsd?: number; } /** * Transaction status */ export interface TxStatus { hash: string; status: 'pending' | 'confirmed' | 'failed' | 'dropped'; blockNumber?: number; confirmations?: number; gasUsed?: number; effectiveGasPrice?: string; error?: string; } /** * Network status */ export interface NetworkStatus { network: string; chainId: number; blockNumber: number; baseFee?: string; gasPrice?: string; connected: boolean; } /** * GT execution result */ export interface GtResult<T = unknown> { success: boolean; data?: T; error?: string; command: string; args: string[]; durationMs: number; } /** * Logger interface */ export interface GtLogger { debug: (msg: string, meta?: Record<string, unknown>) => void; info: (msg: string, meta?: Record<string, unknown>) => void; warn: (msg: string, meta?: Record<string, unknown>) => void; error: (msg: string, meta?: Record<string, unknown>) => void; } // ============================================================================ // Errors // ============================================================================ /** * Gas Town bridge error codes */ export type GtErrorCode = | 'COMMAND_NOT_FOUND' | 'EXECUTION_FAILED' | 'TIMEOUT' | 'INVALID_ARGUMENT' | 'INVALID_OUTPUT' | 'NETWORK_ERROR' | 'VALIDATION_ERROR'; /** * Gas Town bridge error */ export class GtBridgeError extends Error { constructor( message: string, public readonly code: GtErrorCode, public readonly command?: string, public readonly args?: string[], public readonly cause?: Error ) { super(message); this.name = 'GtBridgeError'; } } // ============================================================================ // Default Logger // ============================================================================ const defaultLogger: GtLogger = { debug: (msg, meta) => console.debug(`[gt-bridge] ${msg}`, meta ?? ''), info: (msg, meta) => console.info(`[gt-bridge] ${msg}`, meta ?? ''), warn: (msg, meta) => console.warn(`[gt-bridge] ${msg}`, meta ?? ''), error: (msg, meta) => console.error(`[gt-bridge] ${msg}`, meta ?? ''), }; // ============================================================================ // Allowed Commands // ============================================================================ /** * Allowed gt subcommands (allowlist) */ const ALLOWED_GT_COMMANDS = new Set([ 'estimate', 'status', 'network', 'price', 'tx', 'simulate', 'decode', 'encode', 'help', 'version', 'config', ]); // ============================================================================ // Gas Town Bridge Implementation // ============================================================================ /** * Gas Town CLI Bridge * * Secure wrapper around the `gt` CLI tool for gas estimation * and transaction management. * * @example * ```typescript * const gtBridge = new GtBridge({ gtPath: '/usr/local/bin/gt' }); * await gtBridge.initialize(); * * const estimate = await gtBridge.estimateGas({ * to: '0x...', * data: '0x...', * network: 'mainnet', * }); * ``` */ export class GtBridge { private config: Required<GtBridgeConfig>; private logger: GtLogger; private initialized = false; /** Commands that can be cached (read-only, no side effects) */ private static readonly CACHEABLE_COMMANDS = new Set([ 'version', 'status', 'price', 'decode', 'help', 'config', ]); /** Commands that should use longer cache (static data) */ private static readonly STATIC_COMMANDS = new Set([ 'version', 'help', 'decode', ]); constructor(config?: GtBridgeConfig, logger?: GtLogger) { this.config = { gtPath: config?.gtPath ?? 'gt', cwd: config?.cwd ?? process.cwd(), timeout: config?.timeout ?? 30000, maxBuffer: config?.maxBuffer ?? 10 * 1024 * 1024, env: config?.env ?? process.env, defaultNetwork: config?.defaultNetwork ?? 'mainnet', }; this.logger = logger ?? defaultLogger; } /** * Initialize the bridge and verify gt is available */ async initialize(): Promise<void> { try { const result = await this.execGt(['version']); if (!result.success) { throw new GtBridgeError( 'Failed to verify gt installation', 'COMMAND_NOT_FOUND', 'gt', ['version'] ); } this.initialized = true; this.logger.info('Gas Town bridge initialized', { gtPath: this.config.gtPath, version: result.data, }); } catch (error) { if (error instanceof GtBridgeError) throw error; throw new GtBridgeError( 'Failed to initialize Gas Town bridge', 'COMMAND_NOT_FOUND', 'gt', ['version'], error as Error ); } } /** * Execute a gt command with validated arguments * * @param args - Command arguments (validated and sanitized) * @returns Command output */ async execGt(args: string[], skipCache = false): Promise<GtResult<string>> { const startTime = Date.now(); // Validate all arguments const validatedArgs = this.validateAndSanitizeArgs(args); // Validate subcommand is allowed const subcommand = validatedArgs[0]; if (subcommand && !ALLOWED_GT_COMMANDS.has(subcommand)) { throw new GtBridgeError( `Command not allowed: ${subcommand}`, 'INVALID_ARGUMENT', 'gt', validatedArgs ); } // Check cache for cacheable commands const cacheKey = hashArgs(validatedArgs); const isCacheable = !skipCache && subcommand && GtBridge.CACHEABLE_COMMANDS.has(subcommand); const isStatic = subcommand && GtBridge.STATIC_COMMANDS.has(subcommand); if (isCacheable) { const cached = isStatic ? staticCache.get(cacheKey) as GtResult<string> | undefined : resultCache.get(cacheKey); if (cached) { this.logger.debug('Cache hit for gt command', { command: subcommand }); return { ...cached, durationMs: 0, // Cached result }; } } // Use deduplication for concurrent identical calls return execDedup.dedupe(cacheKey, async () => { try { this.logger.debug('Executing gt command', { command: 'gt', args: validatedArgs, }); const { stdout, stderr } = await execFileAsync( this.config.gtPath, validatedArgs, { cwd: this.config.cwd, env: this.config.env, timeout: this.config.timeout, maxBuffer: this.config.maxBuffer, shell: false, // CRITICAL: Never use shell windowsHide: true, } ); const durationMs = Date.now() - startTime; if (stderr && stderr.trim()) { this.logger.warn('gt stderr output', { stderr }); } const result: GtResult<string> = { success: true, data: stdout.trim(), command: 'gt', args: validatedArgs, durationMs, }; // Cache successful results if (isCacheable && result.success) { if (isStatic) { staticCache.set(cacheKey, result); } else { resultCache.set(cacheKey, result); } } return result; } catch (error: unknown) { const durationMs = Date.now() - startTime; const err = error as NodeJS.ErrnoException & { killed?: boolean; stdout?: string; stderr?: string; }; if (err.killed) { throw new GtBridgeError( 'Command execution timed out', 'TIMEOUT', 'gt', validatedArgs ); } if (err.code === 'ENOENT') { throw new GtBridgeError( `gt executable not found at: ${this.config.gtPath}`, 'COMMAND_NOT_FOUND', 'gt', validatedArgs ); } return { success: false, error: err.stderr || err.message, command: 'gt', args: validatedArgs, durationMs, }; } }); } /** * Parse JSON output from gt command * * @param output - Raw command output * @returns Parsed JSON object */ parseGtOutput<T>(output: string): T { if (!output || output.trim() === '') { throw new GtBridgeError( 'Empty output from gt command', 'INVALID_OUTPUT' ); } // Check parsed cache first const cacheKey = hashArgs([output]); const cached = parsedCache.get(cacheKey); if (cached !== undefined) { return cached as T; } try { // Try to parse as JSON const parsed = JSON.parse(output) as T; parsedCache.set(cacheKey, parsed); return parsed; } catch { // If not JSON, try to extract JSON from output const jsonMatch = output.match(/\{[\s\S]*\}|\[[\s\S]*\]/); if (jsonMatch) { try { const parsed = JSON.parse(jsonMatch[0]) as T; parsedCache.set(cacheKey, parsed); return parsed; } catch { throw new GtBridgeError( 'Failed to parse gt output as JSON', 'INVALID_OUTPUT' ); } } throw new GtBridgeError( 'Output is not valid JSON', 'INVALID_OUTPUT' ); } } /** * Estimate gas for a transaction */ async estimateGas(params: { to: string; data?: string; value?: string; from?: string; network?: z.infer<typeof NetworkSchema>; }): Promise<GasEstimate> { this.ensureInitialized(); // Validate parameters const validatedTo = AddressSchema.parse(params.to); const network = params.network ?? this.config.defaultNetwork; const args = ['estimate', '--to', validatedTo, '--network', network, '--json']; if (params.data) { const validatedData = SafeStringSchema.parse(params.data); args.push('--data', validatedData); } if (params.value) { const validatedValue = SafeStringSchema.parse(params.value); args.push('--value', validatedValue); } if (params.from) { const validatedFrom = AddressSchema.parse(params.from); args.push('--from', validatedFrom); } const result = await this.execGt(args); if (!result.success || !result.data) { throw new GtBridgeError( result.error ?? 'Gas estimation failed', 'EXECUTION_FAILED', 'gt', args ); } return this.parseGtOutput<GasEstimate>(result.data); } /** * Get transaction status */ async getTxStatus(txHash: string, network?: z.infer<typeof NetworkSchema>): Promise<TxStatus> { this.ensureInitialized(); const validatedHash = TxHashSchema.parse(txHash); const args = [ 'tx', 'status', validatedHash, '--network', network ?? this.config.defaultNetwork, '--json', ]; const result = await this.execGt(args); if (!result.success || !result.data) { throw new GtBridgeError( result.error ?? 'Failed to get transaction status', 'EXECUTION_FAILED', 'gt', args ); } return this.parseGtOutput<TxStatus>(result.data); } /** * Get network status */ async getNetworkStatus(network?: z.infer<typeof NetworkSchema>): Promise<NetworkStatus> { this.ensureInitialized(); const args = [ 'network', 'status', '--network', network ?? this.config.defaultNetwork, '--json', ]; const result = await this.execGt(args); if (!result.success || !result.data) { throw new GtBridgeError( result.error ?? 'Failed to get network status', 'NETWORK_ERROR', 'gt', args ); } return this.parseGtOutput<NetworkStatus>(result.data); } /** * Get current gas price */ async getGasPrice(network?: z.infer<typeof NetworkSchema>): Promise<{ gasPrice: string; maxFeePerGas?: string; maxPriorityFeePerGas?: string; baseFee?: string; }> { this.ensureInitialized(); const args = [ 'price', '--network', network ?? this.config.defaultNetwork, '--json', ]; const result = await this.execGt(args); if (!result.success || !result.data) { throw new GtBridgeError( result.error ?? 'Failed to get gas price', 'EXECUTION_FAILED', 'gt', args ); } return this.parseGtOutput(result.data); } /** * Simulate a transaction */ async simulate(params: { to: string; data: string; value?: string; from?: string; network?: z.infer<typeof NetworkSchema>; blockNumber?: number; }): Promise<{ success: boolean; returnData?: string; gasUsed?: number; logs?: unknown[]; error?: string; }> { this.ensureInitialized(); const validatedTo = AddressSchema.parse(params.to); const validatedData = SafeStringSchema.parse(params.data); const network = params.network ?? this.config.defaultNetwork; const args = [ 'simulate', '--to', validatedTo, '--data', validatedData, '--network', network, '--json', ]; if (params.value) { args.push('--value', SafeStringSchema.parse(params.value)); } if (params.from) { args.push('--from', AddressSchema.parse(params.from)); } if (params.blockNumber !== undefined) { args.push('--block', String(params.blockNumber)); } const result = await this.execGt(args); if (!result.success || !result.data) { throw new GtBridgeError( result.error ?? 'Transaction simulation failed', 'EXECUTION_FAILED', 'gt', args ); } return this.parseGtOutput(result.data); } /** * Decode transaction data */ async decode(data: string, abi?: string): Promise<{ method: string; args: unknown[]; signature: string; }> { this.ensureInitialized(); const validatedData = SafeStringSchema.parse(data); const args = ['decode', validatedData, '--json']; if (abi) { const validatedAbi = SafeStringSchema.parse(abi); args.push('--abi', validatedAbi); } const result = await this.execGt(args); if (!result.success || !result.data) { throw new GtBridgeError( result.error ?? 'Failed to decode transaction data', 'EXECUTION_FAILED', 'gt', args ); } return this.parseGtOutput(result.data); } /** * Validate and sanitize command arguments */ private validateAndSanitizeArgs(args: string[]): string[] { return args.map((arg, index) => { try { return GtArgumentSchema.parse(arg); } catch (error) { throw new GtBridgeError( `Invalid argument at index ${index}: ${arg}`, 'VALIDATION_ERROR', 'gt', args, error as Error ); } }); } /** * Ensure bridge is initialized */ private ensureInitialized(): void { if (!this.initialized) { throw new GtBridgeError( 'Gas Town bridge not initialized. Call initialize() first.', 'EXECUTION_FAILED' ); } } /** * Check if bridge is initialized */ isInitialized(): boolean { return this.initialized; } /** * Get current configuration */ getConfig(): Readonly<Required<GtBridgeConfig>> { return { ...this.config }; } /** * Get cache statistics for performance monitoring */ getCacheStats(): { resultCache: { entries: number; sizeBytes: number }; staticCache: { entries: number; sizeBytes: number }; parsedCache: { entries: number; sizeBytes: number }; } { return { resultCache: resultCache.stats(), staticCache: staticCache.stats(), parsedCache: parsedCache.stats(), }; } /** * Clear all caches (useful for testing or memory pressure) */ clearCaches(): void { resultCache.clear(); staticCache.clear(); parsedCache.clear(); } } /** * Create a new Gas Town bridge instance */ export function createGtBridge(config?: GtBridgeConfig, logger?: GtLogger): GtBridge { return new GtBridge(config, logger); } // Export schemas for external use export { SafeStringSchema, IdentifierSchema, GasPriceSchema, GasLimitSchema, TxHashSchema, AddressSchema, NetworkSchema, GtArgumentSchema, }; export default GtBridge;