/** * Git URL install handler for skills. * POST /api/skills/install-from-url * * Two modes: * - Preview: POST { url } → returns detected skills + scan findings * - Install: POST { url, selectedSkills: [...] } → installs selected skills */ import type { Request, Response } from 'express'; import { existsSync, mkdirSync, mkdtempSync, rmSync, cpSync, readdirSync, lstatSync, readFileSync, writeFileSync, } from 'fs'; import { join, relative } from 'path'; import { execFileSync } from 'child_process'; import { tmpdir } from 'os'; import matter from 'gray-matter'; import type { SkillCatalog } from '../engine/skills.js'; import { VALID_SKILL_NAME } from '../engine/skills.js'; import { scanSkillContent, scanSkillDirectory, maxSeverity, type ScanFinding } from '../engine/skills-scanner.js'; import { logger } from '../logger.js'; // ── Types ─────────────────────────────────────────────────────────────────── export interface GitInstallDeps { skillCatalog: SkillCatalog; auditLog?: (jobId: string | null, action: string, actor: string, detail: object) => Promise; } export interface DetectedSkill { name: string; description: string; relativePath: string; fullPath: string; isDir: boolean; findings: ScanFinding[]; maxSeverity: 'high' | 'medium' | 'none'; } // ── Constants ─────────────────────────────────────────────────────────────── const MAX_REPO_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB const GIT_CLONE_TIMEOUT_MS = 30_000; const SKIP_DIRS = new Set(['.git', 'node_modules', '.github', '.vscode']); // ── Helpers ───────────────────────────────────────────────────────────────── /** * Walk a cloned directory looking for skills: * - Directories containing SKILL.md * - Standalone .md files with frontmatter `name` */ export function detectSkillsInDir(rootDir: string): DetectedSkill[] { const results: DetectedSkill[] = []; function walk(dir: string): void { let entries: string[]; try { entries = readdirSync(dir); } catch { return; } for (const entry of entries) { const fullPath = join(dir, entry); let stat; try { stat = lstatSync(fullPath); } catch { continue; } // Skip symlinks entirely (security) if (stat.isSymbolicLink()) continue; if (stat.isDirectory()) { if (SKIP_DIRS.has(entry)) continue; // Check if this directory is a skill (has SKILL.md) const skillMdPath = join(fullPath, 'SKILL.md'); if (existsSync(skillMdPath)) { try { const skillStat = lstatSync(skillMdPath); if (skillStat.isSymbolicLink()) continue; // Skip symlinked SKILL.md } catch { continue; } try { const raw = readFileSync(skillMdPath, 'utf-8'); const { data } = matter(raw); if (data && typeof data.name === 'string' && data.name && VALID_SKILL_NAME.test(data.name)) { const findings = scanSkillDirectory(fullPath); results.push({ name: data.name, description: typeof data.description === 'string' ? data.description : '', relativePath: relative(rootDir, fullPath), fullPath, isDir: true, findings, maxSeverity: maxSeverity(findings), }); } } catch { // Unreadable SKILL.md — skip } // Don't recurse into skill directories (they're self-contained) continue; } // Not a skill dir — recurse walk(fullPath); continue; } // Standalone .md file if (stat.isFile() && entry.endsWith('.md')) { try { const raw = readFileSync(fullPath, 'utf-8'); const { data } = matter(raw); if (data && typeof data.name === 'string' && data.name && VALID_SKILL_NAME.test(data.name)) { const findings = scanSkillContent(raw); results.push({ name: data.name, description: typeof data.description === 'string' ? data.description : '', relativePath: relative(rootDir, fullPath), fullPath, isDir: false, findings, maxSeverity: maxSeverity(findings), }); } } catch { // Unreadable .md — skip } } } } walk(rootDir); return results; } /** * Validate that a URL is safe for git clone (SSRF defense). * Only HTTPS URLs are allowed. */ export function validateUrl(url: string): string | null { if (!url || typeof url !== 'string') return 'url is required'; const trimmed = url.trim(); if (!trimmed.startsWith('https://')) { return 'Only https:// URLs are allowed (http://, file://, ssh://, git:// and local paths are rejected for security)'; } // Reject control characters (newlines, NUL, etc.), shell metacharacters, // quotes and whitespace. The clone now runs via execFile (no shell), so this // is defense-in-depth, but it also prevents a newline+quote breakout if the // URL is ever reused in a shell context. if (/[\u0000-\u001f\u007f;&|`$"'\\<>(){}\s]/.test(trimmed)) { return 'URL contains disallowed characters'; } // Must parse as a real https URL. let parsed: URL; try { parsed = new URL(trimmed); } catch { return 'URL is not a valid URL'; } if (parsed.protocol !== 'https:') { return 'Only https:// URLs are allowed'; } return null; } // ── Handler ───────────────────────────────────────────────────────────────── export function handleInstallFromUrl(deps: GitInstallDeps): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { const { url, scope: rawScope, selectedSkills } = req.body ?? {}; // 1. Validate URL const urlError = validateUrl(url); if (urlError) { res.status(400).json({ error: urlError }); return; } // 2. Validate scope const scope: 'system' | 'user' = rawScope === 'system' ? 'system' : 'user'; // 3. System scope requires admin const user = req.user as Express.User | undefined; if (scope === 'system' && (!user || user.role !== 'admin')) { res.status(403).json({ error: 'System-scope install requires admin role' }); return; } const userId = user?.id ?? 'anonymous'; // 4. Clone to temp directory const tmpBase = mkdtempSync(join(tmpdir(), 'skill-git-')); const cloneDir = join(tmpBase, 'repo'); try { try { // execFile (no shell): url and cloneDir are passed as literal argv // entries, so shell metacharacters in `url` cannot inject commands. // `--` terminates option parsing so a `url` starting with `-` cannot // be treated as a git flag (defense-in-depth; validateUrl already // requires an https:// prefix). execFileSync( 'git', ['clone', '--depth', '1', '--no-recurse-submodules', '--no-checkout', '--', url, cloneDir], { timeout: GIT_CLONE_TIMEOUT_MS, stdio: 'pipe' }, ); execFileSync( 'git', ['-C', cloneDir, 'checkout', 'HEAD', '--', '.'], { timeout: GIT_CLONE_TIMEOUT_MS, stdio: 'pipe' }, ); } catch (cloneErr: unknown) { const isTimeout = cloneErr instanceof Error && 'killed' in cloneErr && (cloneErr as any).killed; if (isTimeout) { res.status(408).json({ error: 'Git clone timed out (30s limit)' }); return; } const msg = cloneErr instanceof Error ? cloneErr.message : String(cloneErr); res.status(400).json({ error: `Git clone failed: ${msg.slice(0, 300)}` }); return; } // 5. Verify clone exists if (!existsSync(cloneDir)) { res.status(400).json({ error: 'Git clone produced no output directory' }); return; } // 6. Size check try { const duOutput = execFileSync('du', ['-sb', cloneDir], { encoding: 'utf-8', timeout: 10_000 }); const sizeBytes = parseInt(duOutput.split('\t')[0], 10); if (sizeBytes > MAX_REPO_SIZE_BYTES) { res.status(400).json({ error: `Repository too large: ${Math.round(sizeBytes / 1024 / 1024)}MB exceeds 50MB limit`, }); return; } } catch { // du failed — continue (non-critical) logger.warn('[skills-git-install] du -sb failed, skipping size check'); } // 7. Detect skills const detected = detectSkillsInDir(cloneDir); if (detected.length === 0) { res.status(400).json({ error: 'No skills found in repository. Skills must be directories with SKILL.md or standalone .md files with frontmatter "name".', }); return; } // 8. Preview mode — explicitly requested via preview flag const previewMode = req.body?.preview === true; if (previewMode) { const preview = detected.map(s => ({ name: s.name, description: s.description, relativePath: s.relativePath, isDir: s.isDir, findings: s.findings, maxSeverity: s.maxSeverity, })); res.json({ preview, totalDetected: detected.length }); return; } // 9. Install mode — selectedSkills or all detected const selectedSet = Array.isArray(selectedSkills) && selectedSkills.length > 0 ? new Set(selectedSkills.filter((s: unknown) => typeof s === 'string')) : new Set(detected.map(s => s.name)); if (selectedSet.size === 0) { res.status(400).json({ error: 'No skills detected in repository' }); return; } // Resolve target directory const targetDir = scope === 'system' ? deps.skillCatalog.getSystemDir() : deps.skillCatalog.getUserSkillDir(userId); if (!existsSync(targetDir)) { mkdirSync(targetDir, { recursive: true }); } const installed: string[] = []; const errors: string[] = []; for (const skill of detected) { if (!selectedSet.has(skill.name)) continue; try { if (skill.isDir) { // Copy entire skill directory const destDir = join(targetDir, skill.name); cpSync(skill.fullPath, destDir, { recursive: true }); } else { // Single .md file → create as directory format ({name}/SKILL.md) const content = readFileSync(skill.fullPath, 'utf-8'); const destDir = join(targetDir, skill.name); mkdirSync(destDir, { recursive: true }); writeFileSync(join(destDir, 'SKILL.md'), content, 'utf-8'); } installed.push(skill.name); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); errors.push(`${skill.name}: ${msg.slice(0, 200)}`); logger.warn(`[skills-git-install] failed to install skill=${skill.name} err=${msg}`); } } // Check for requested skills that weren't found in the repo for (const name of selectedSet) { if (!installed.includes(name) && !errors.some(e => e.startsWith(`${name}:`))) { errors.push(`${name}: not found in repository`); } } // 10. Invalidate cache if (scope === 'system') { deps.skillCatalog.refreshSystem(); } else { deps.skillCatalog.invalidate(userId); } // 11. Audit log if (deps.auditLog && installed.length > 0) { deps.auditLog(null, 'skill_install_from_url', userId, { url, scope, installed, errors: errors.length > 0 ? errors : undefined, }).catch(err => { logger.warn(`[skills-git-install] audit log failed err=${err}`); }); } logger.info(`[skills-git-install] installed=${installed.length} errors=${errors.length} scope=${scope} user=${userId}`); // 12. Return result res.json({ installed, errors: errors.length > 0 ? errors : undefined }); } finally { // Always clean up temp directory try { rmSync(tmpBase, { recursive: true, force: true }); } catch (cleanupErr) { logger.warn(`[skills-git-install] tmpdir cleanup failed: ${cleanupErr}`); } } }; }