jay-code
Version:
Streamlined AI CLI orchestration engine with mathematical rigor and enterprise-grade reliability
587 lines (501 loc) • 15.7 kB
JavaScript
/**
* GitHub CLI Safety Wrapper - Production-Ready Implementation
*
* Comprehensive wrapper around GitHub CLI commands with:
* - Injection attack prevention
* - Timeout handling with graceful cleanup
* - Process management and recovery
* - Input validation and sanitization
* - Rate limiting protection
* - Comprehensive error handling
* - Retry logic with exponential backoff
* - Secure temp file handling
* - Logging and monitoring
*
* @version 2.0.0
* @license MIT
*/
import { promises as fs } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
import { randomBytes, createHash } from 'crypto';
import { spawn, execSync } from 'child_process';
import { performance } from 'perf_hooks';
/**
* Configuration constants
*/
const CONFIG = {
DEFAULT_TIMEOUT: 30000, // 30 seconds
MAX_TIMEOUT: 300000, // 5 minutes
MAX_RETRIES: 3,
RETRY_BASE_DELAY: 1000, // 1 second
MAX_BODY_SIZE: 1024 * 1024, // 1MB
RATE_LIMIT_WINDOW: 60000, // 1 minute
MAX_REQUESTS_PER_WINDOW: 50,
TEMP_FILE_PREFIX: 'gh-safe-',
ALLOWED_COMMANDS: [
'auth', 'repo', 'issue', 'pr', 'release', 'gist', 'run', 'workflow',
'api', 'browse', 'config', 'extension', 'gpg-key', 'label', 'project',
'secret', 'ssh-key', 'status', 'variable', 'cache', 'codespace'
],
DANGEROUS_PATTERNS: [
/\$\([^)]*\)/g, // Command substitution $(...)
/`[^`]*`/g, // Backtick execution
/&&|\|\||;|&/g, // Command chaining
/<\(/g, // Process substitution
/>\s*\/dev\/null/g, // Output redirection
/\|\s*sh/g, // Pipe to shell
/eval\s*\(/g, // eval() calls
/exec\s*\(/g, // exec() calls
]
};
/**
* Custom error classes for better error handling
*/
class GitHubCliError extends Error {
constructor(message, code = 'GITHUB_CLI_ERROR', details = {}) {
super(message);
this.name = 'GitHubCliError';
this.code = code;
this.details = details;
this.timestamp = new Date().toISOString();
}
}
class GitHubCliTimeoutError extends GitHubCliError {
constructor(timeout, command) {
super(`Command timed out after ${timeout}ms: ${command}`, 'TIMEOUT', { timeout, command });
this.name = 'GitHubCliTimeoutError';
}
}
class GitHubCliValidationError extends GitHubCliError {
constructor(message, field, value) {
super(message, 'VALIDATION_ERROR', { field, value });
this.name = 'GitHubCliValidationError';
}
}
class GitHubCliRateLimitError extends GitHubCliError {
constructor(message) {
super(message, 'RATE_LIMIT_ERROR');
this.name = 'GitHubCliRateLimitError';
}
}
/**
* Rate limiter to prevent API abuse
*/
class RateLimiter {
constructor(maxRequests = CONFIG.MAX_REQUESTS_PER_WINDOW, windowMs = CONFIG.RATE_LIMIT_WINDOW) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
this.requests = [];
}
async checkLimit() {
const now = Date.now();
this.requests = this.requests.filter(time => now - time < this.windowMs);
if (this.requests.length >= this.maxRequests) {
const oldestRequest = Math.min(...this.requests);
const resetTime = oldestRequest + this.windowMs;
const waitTime = resetTime - now;
throw new GitHubCliRateLimitError(
`Rate limit exceeded. Try again in ${Math.ceil(waitTime / 1000)} seconds`
);
}
this.requests.push(now);
}
}
/**
* Main GitHub CLI Safety Wrapper Class
*/
export class GitHubCliSafe {
constructor(options = {}) {
this.options = {
timeout: options.timeout || CONFIG.DEFAULT_TIMEOUT,
maxRetries: options.maxRetries || CONFIG.MAX_RETRIES,
retryDelay: options.retryDelay || CONFIG.RETRY_BASE_DELAY,
enableRateLimit: options.enableRateLimit !== false,
enableLogging: options.enableLogging !== false,
tempDir: options.tempDir || tmpdir(),
...options
};
this.rateLimiter = new RateLimiter();
this.activeProcesses = new Map();
this.stats = {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
timeoutRequests: 0,
retriedRequests: 0
};
}
/**
* Input validation and sanitization
*/
validateCommand(command) {
if (typeof command !== 'string' || !command.trim()) {
throw new GitHubCliValidationError('Command must be a non-empty string', 'command', command);
}
const parts = command.trim().split(' ');
const mainCommand = parts[0];
if (!CONFIG.ALLOWED_COMMANDS.includes(mainCommand)) {
throw new GitHubCliValidationError(
`Command '${mainCommand}' is not allowed`,
'command',
mainCommand
);
}
return command;
}
sanitizeInput(input) {
if (typeof input !== 'string') {
input = String(input);
}
// Check for dangerous patterns
for (const pattern of CONFIG.DANGEROUS_PATTERNS) {
if (pattern.test(input)) {
throw new GitHubCliValidationError(
`Input contains potentially dangerous pattern: ${pattern}`,
'input',
input
);
}
}
return input;
}
validateBodySize(body) {
if (Buffer.byteLength(body, 'utf8') > CONFIG.MAX_BODY_SIZE) {
throw new GitHubCliValidationError(
`Body size exceeds maximum allowed size of ${CONFIG.MAX_BODY_SIZE} bytes`,
'body',
body.length
);
}
}
/**
* Secure temporary file handling
*/
async createSecureTempFile(content, suffix = '.tmp') {
const filename = `${CONFIG.TEMP_FILE_PREFIX}${randomBytes(16).toString('hex')}${suffix}`;
const filepath = resolve(this.options.tempDir, filename);
// Validate content size
this.validateBodySize(content);
// Create file with restricted permissions (600 - owner read/write only)
await fs.writeFile(filepath, content, { mode: 0o600 });
return filepath;
}
async cleanupTempFile(filepath) {
try {
await fs.unlink(filepath);
} catch (error) {
if (this.options.enableLogging) {
console.warn(`Failed to cleanup temp file ${filepath}:`, error.message);
}
}
}
/**
* Process management with timeout and cleanup
*/
async executeWithTimeout(command, args, options = {}) {
const timeout = Math.min(options.timeout || this.options.timeout, CONFIG.MAX_TIMEOUT);
const processId = randomBytes(8).toString('hex');
return new Promise((resolve, reject) => {
const startTime = performance.now();
const child = spawn('gh', args, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: false, // Critical: prevent shell injection
env: { ...process.env, ...options.env },
cwd: options.cwd || process.cwd()
});
this.activeProcesses.set(processId, child);
let stdout = '';
let stderr = '';
let isTimedOut = false;
let isResolved = false;
// Timeout handler
const timer = setTimeout(() => {
if (!isResolved) {
isTimedOut = true;
this.killProcess(child, processId);
this.stats.timeoutRequests++;
reject(new GitHubCliTimeoutError(timeout, `gh ${args.join(' ')}`));
}
}, timeout);
// Data handlers
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
// Process completion handler
child.on('close', (code, signal) => {
if (isResolved) return;
isResolved = true;
clearTimeout(timer);
this.activeProcesses.delete(processId);
const duration = performance.now() - startTime;
if (isTimedOut) {
return; // Already handled by timeout
}
if (signal === 'SIGKILL' || signal === 'SIGTERM') {
reject(new GitHubCliError(
`Process terminated by signal ${signal}`,
'PROCESS_TERMINATED',
{ signal, code, duration }
));
return;
}
if (code !== 0) {
this.stats.failedRequests++;
reject(new GitHubCliError(
`Command failed with exit code ${code}: ${stderr || 'No error details'}`,
'COMMAND_FAILED',
{ code, stderr, stdout, duration, command: `gh ${args.join(' ')}` }
));
return;
}
this.stats.successfulRequests++;
resolve({
stdout: stdout.trim(),
stderr: stderr.trim(),
code,
duration,
command: `gh ${args.join(' ')}`
});
});
// Error handler
child.on('error', (error) => {
if (isResolved) return;
isResolved = true;
clearTimeout(timer);
this.activeProcesses.delete(processId);
this.stats.failedRequests++;
reject(new GitHubCliError(
`Process error: ${error.message}`,
'PROCESS_ERROR',
{ originalError: error }
));
});
});
}
killProcess(child, processId) {
try {
// Graceful termination first
child.kill('SIGTERM');
// Force kill after 5 seconds if still running
setTimeout(() => {
if (this.activeProcesses.has(processId)) {
child.kill('SIGKILL');
this.activeProcesses.delete(processId);
}
}, 5000);
} catch (error) {
if (this.options.enableLogging) {
console.warn(`Failed to kill process ${processId}:`, error.message);
}
}
}
/**
* Retry logic with exponential backoff
*/
async withRetry(operation, maxRetries = this.options.maxRetries) {
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
this.stats.retriedRequests++;
const delay = this.options.retryDelay * Math.pow(2, attempt - 1);
await this.sleep(delay);
}
return await operation();
} catch (error) {
lastError = error;
// Don't retry on validation errors or rate limits
if (error instanceof GitHubCliValidationError ||
error instanceof GitHubCliRateLimitError) {
throw error;
}
if (attempt === maxRetries) {
break;
}
if (this.options.enableLogging) {
console.warn(`Attempt ${attempt + 1} failed, retrying:`, error.message);
}
}
}
throw lastError;
}
async sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Core execution method with all safety features
*/
async execute(command, options = {}) {
this.stats.totalRequests++;
// Rate limiting check
if (this.options.enableRateLimit) {
await this.rateLimiter.checkLimit();
}
// Validate and sanitize command
const validatedCommand = this.validateCommand(command);
const args = validatedCommand.split(' ');
// Handle body content if provided
let tempFile = null;
try {
if (options.body) {
const sanitizedBody = this.sanitizeInput(options.body);
tempFile = await this.createSecureTempFile(sanitizedBody);
args.push('--body-file', tempFile);
}
// Add other options
if (options.title) {
args.push('--title', this.sanitizeInput(options.title));
}
if (options.labels && Array.isArray(options.labels)) {
const sanitizedLabels = options.labels.map(label => this.sanitizeInput(label));
args.push('--label', sanitizedLabels.join(','));
}
if (options.assignees && Array.isArray(options.assignees)) {
const sanitizedAssignees = options.assignees.map(assignee => this.sanitizeInput(assignee));
args.push('--assignee', sanitizedAssignees.join(','));
}
// Add any additional flags
if (options.flags) {
for (const [flag, value] of Object.entries(options.flags)) {
args.push(`--${flag}`);
if (value !== true) {
args.push(this.sanitizeInput(String(value)));
}
}
}
// Execute with retry logic
return await this.withRetry(async () => {
return await this.executeWithTimeout(validatedCommand, args.slice(1), options);
});
} finally {
// Always cleanup temp file
if (tempFile) {
await this.cleanupTempFile(tempFile);
}
}
}
/**
* High-level methods for common GitHub operations
*/
async createIssue({ title, body, labels = [], assignees = [], ...options }) {
return await this.execute('issue create', {
title,
body,
labels,
assignees,
...options
});
}
async createPR({ title, body, base = 'main', head, draft = false, ...options }) {
const flags = { base };
if (head) flags.head = head;
if (draft) flags.draft = true;
return await this.execute('pr create', {
title,
body,
flags,
...options
});
}
async addIssueComment(issueNumber, body, options = {}) {
return await this.execute(`issue comment ${issueNumber}`, {
body,
...options
});
}
async addPRComment(prNumber, body, options = {}) {
return await this.execute(`pr comment ${prNumber}`, {
body,
...options
});
}
async createRelease({ tag, title, body, prerelease = false, draft = false, ...options }) {
const flags = { tag };
if (prerelease) flags.prerelease = true;
if (draft) flags.draft = true;
return await this.execute('release create', {
title,
body,
flags,
...options
});
}
/**
* Utility methods
*/
async checkGitHubCli() {
try {
execSync('gh --version', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
async checkAuthentication() {
try {
const result = await this.execute('auth status');
return result.code === 0;
} catch {
return false;
}
}
getStats() {
return { ...this.stats };
}
getActiveProcessCount() {
return this.activeProcesses.size;
}
async cleanup() {
// Kill all active processes
for (const [processId, child] of this.activeProcesses) {
this.killProcess(child, processId);
}
this.activeProcesses.clear();
}
}
/**
* Factory function for creating configured instances
*/
export function createGitHubCliSafe(options = {}) {
return new GitHubCliSafe(options);
}
/**
* Default singleton instance
*/
export const githubCli = new GitHubCliSafe();
/**
* Legacy compatibility functions
*/
export async function safeGhCommand(command, target, body, options = {}) {
return await githubCli.execute(`${command} ${target}`, { body, ...options });
}
export const gh = {
async issueComment(issue, body, options = {}) {
return await githubCli.addIssueComment(issue, body, options);
},
async prComment(pr, body, options = {}) {
return await githubCli.addPRComment(pr, body, options);
},
async createIssue(params) {
return await githubCli.createIssue(params);
},
async createPR(params) {
return await githubCli.createPR(params);
}
};
/**
* Process cleanup on exit
*/
process.on('SIGINT', async () => {
await githubCli.cleanup();
process.exit(0);
});
process.on('SIGTERM', async () => {
await githubCli.cleanup();
process.exit(0);
});
export default GitHubCliSafe;