-
Notifications
You must be signed in to change notification settings - Fork 0
Open
Labels
area:toolingArea: tooling and CLIArea: tooling and CLIenhancementNew feature or requestNew feature or request
Description
Overview
Multiple issues relate to making the CLI and config loading more resilient to edge cases and user errors. This tracking issue proposes hardened tooling infrastructure.
Related Issues
- Add pythonPath/localModules config option for local Python modules #131 - Add pythonPath/localModules config option for local Python modules
- Better error messages when code generation fails or finds no modules #132 - Better error messages when code generation fails or finds no modules
- Config loader should not require write access to config directory #62 - Config loader should not require write access to config directory
- computeCacheKey should sanitize moduleName to avoid path traversal/invalid filenames #67 - computeCacheKey should sanitize moduleName to avoid path traversal
- processUtils.exec should support timeouts for Python IR/discovery calls #68 - processUtils.exec should support timeouts for Python IR/discovery calls
- Cache key generation should disambiguate input types to avoid collisions #73 - Cache key generation should disambiguate input types
Current Problems
- Write access required - Transpiles config next to source file
- No timeouts - Hung Python process blocks forever
- Path traversal - Module name
../../etc/passwdescapes cache - Silent failures - "Generated:" with no output on import error
- Type collisions - String
"1"and number1hash the same
Architectural Solution: Defense-in-Depth CLI
┌─────────────────────────────────────────────────────────────┐
│ CLI Entrypoint │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ SafeConfig │ │ TimedRunner │ │ SecureCache │ │
│ │ Loader │ │ │ │ │ │
│ ├──────────────┤ ├──────────────┤ ├──────────────┤ │
│ │ • OS temp │ │ • 30s timeout│ │ • Hash-only │ │
│ │ • Fallback │ │ • Retry logic│ │ filenames │ │
│ │ • Cleanup │ │ • Kill tree │ │ • Type tags │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ RichErrorReporter │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ • Structured errors with context │ │
│ │ • Hints for common issues │ │
│ │ • Exit codes for CI integration │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Key Implementation Details
1. Safe Config Loading (#62)
class SafeConfigLoader {
private readonly tempDir: string;
constructor() {
// Use OS temp, not config directory
this.tempDir = path.join(os.tmpdir(), 'tywrap-config');
fs.mkdirSync(this.tempDir, { recursive: true });
}
async load(configPath: string): Promise<TywrapConfig> {
const ext = path.extname(configPath);
if (ext === '.ts' || ext === '.mts') {
// Transpile to temp directory
const hash = crypto.createHash('sha256')
.update(configPath)
.digest('hex')
.slice(0, 8);
const tempFile = path.join(this.tempDir, `config-${hash}.mjs`);
await this.transpile(configPath, tempFile);
try {
return (await import(tempFile)).default;
} finally {
fs.unlinkSync(tempFile); // Cleanup
}
}
return (await import(configPath)).default;
}
}2. Timed Process Runner (#68)
class TimedProcessRunner {
async exec(
command: string,
args: string[],
options: { timeoutMs?: number; cwd?: string } = {}
): Promise<{ stdout: string; stderr: string }> {
const { timeoutMs = 30000 } = options;
return new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd: options.cwd });
let stdout = '', stderr = '';
const timeout = setTimeout(() => {
child.kill('SIGKILL');
reject(new TimeoutError(
`Command timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`
));
}, timeoutMs);
child.stdout.on('data', d => stdout += d);
child.stderr.on('data', d => stderr += d);
child.on('close', code => {
clearTimeout(timeout);
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new ProcessError(code, stderr));
}
});
});
}
}3. Secure Cache Manager (#67, #73)
class SecureCacheManager {
private readonly cacheDir: string;
computeKey(...inputs: unknown[]): string {
// Type-tagged serialization prevents collisions
const tagged = inputs.map(v => {
const type = v === null ? 'null' : typeof v;
return `${type}:${JSON.stringify(v)}`;
});
// Hash-only filename (no user input in path)
const hash = crypto.createHash('sha256')
.update(tagged.join('\0'))
.digest('hex');
return hash.slice(0, 32); // 128 bits is sufficient
}
getPath(key: string): string {
// Validate key is hex-only (defense in depth)
if (!/^[a-f0-9]+$/.test(key)) {
throw new Error('Invalid cache key');
}
return path.join(this.cacheDir, `${key}.json`);
}
}4. Rich Error Reporter (#132)
class RichErrorReporter {
moduleNotFound(module: string, pythonError: string): never {
console.error(`\n❌ Failed to import Python module: ${module}\n`);
console.error(`Python error:`);
console.error(` ${pythonError.split('\n').join('\n ')}\n`);
console.error(`Troubleshooting:`);
console.error(` 1. Verify the module is installed: pip show ${module}`);
console.error(` 2. Check PYTHONPATH: ${process.env.PYTHONPATH || '(not set)'}`);
console.error(` 3. Add to tywrap.config.ts:`);
console.error(` pythonPath: ['./path/to/local/modules']`);
process.exit(1);
}
noModulesGenerated(config: TywrapConfig): never {
const modules = Object.keys(config.pythonModules || {});
console.error(`\n⚠️ No TypeScript wrappers were generated.\n`);
if (modules.length === 0) {
console.error(`No modules configured in pythonModules.`);
} else {
console.error(`Configured modules: ${modules.join(', ')}`);
console.error(`Run with --verbose to see import errors.`);
}
process.exit(1);
}
}5. Python Path Config (#131)
interface TywrapConfig {
pythonModules: Record<string, ModuleConfig>;
pythonPath?: string[]; // Added: prepended to PYTHONPATH
output: OutputConfig;
}
// In IR fetcher:
function buildEnv(config: TywrapConfig): NodeJS.ProcessEnv {
const env = { ...process.env };
if (config.pythonPath?.length) {
const existing = env.PYTHONPATH || '';
env.PYTHONPATH = [...config.pythonPath, existing].filter(Boolean).join(':');
}
return env;
}Testing Strategy
- Add adversarial tests for path traversal attempts
- Test read-only directory scenarios
- Test timeout behavior with hung processes
- Test type collision scenarios
Expectations
- CLI works on read-only filesystems (CI containers)
- Hung Python processes killed after 30s with clear error
- No path traversal possible via malicious module names
- Clear, actionable error messages with hints
pythonPathconfig option for local modules
Metadata
Metadata
Assignees
Labels
area:toolingArea: tooling and CLIArea: tooling and CLIenhancementNew feature or requestNew feature or request