Skip to content

arch: CLI and config robustness #147

@bbopen

Description

@bbopen

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

Current Problems

  1. Write access required - Transpiles config next to source file
  2. No timeouts - Hung Python process blocks forever
  3. Path traversal - Module name ../../etc/passwd escapes cache
  4. Silent failures - "Generated:" with no output on import error
  5. Type collisions - String "1" and number 1 hash 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
  • pythonPath config option for local modules

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions