import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { loadRules, type Rules } from "./damage-prevention-rules.js"; import { evaluateGitSecretBlock, sanitizeGitCommandForLogging, } from "./git-secret-blocking.js"; import { isPathMatch, resolvePath } from "./path-utils.js"; import { compileSecretPatternsSafe } from "./secret-patterns.js"; export type SafeRunnerName = | "run_biome" | "run_vitest" | "run_typecheck" | "run_pytest" | "run_cargo_test" | "git_safe" | "gh_safe"; export interface RunBiomeParams { paths?: string[]; timeoutMs?: number; } export interface RunVitestParams { paths?: string[]; testNamePattern?: string; timeoutMs?: number; } export interface RunTypecheckParams { project?: string; timeoutMs?: number; } export interface RunPytestParams { paths?: string[]; keyword?: string; timeoutMs?: number; } export interface RunCargoTestParams { package?: string; testNamePattern?: string; timeoutMs?: number; } export interface GitSafeParams { action: "status" | "diff" | "add" | "commit" | "push" | "init"; paths?: string[]; cached?: boolean; message?: string; remote?: string; branch?: string; name?: string; sourcePath?: string; timeoutMs?: number; } export interface GhSafeParams { action: "repo_create" | "pr_create" | "pr_edit" | "pr_merge" | "pr_view"; name?: string; visibility?: "public" | "private"; sourcePath?: string; remote?: string; push?: boolean; title?: string; body?: string; base?: string; draft?: boolean; squash?: boolean; deleteBranch?: boolean; timeoutMs?: number; } export type SafeRunnerParams = | RunBiomeParams | RunVitestParams | RunTypecheckParams | RunPytestParams | RunCargoTestParams | GitSafeParams | GhSafeParams; export interface SafeCommandPlan { toolName: SafeRunnerName; executable: string; args: string[]; cwd: string; displayCommand: string; timeoutMs: number; } export interface SafeCommandExecutionResult { toolName: SafeRunnerName; executable: string; args: string[]; cwd: string; displayCommand: string; exitCode: number | null; timedOut: boolean; aborted: boolean; /** True when the child process could not be spawned at all. */ spawnError: boolean; stdout: string; stderr: string; output: string; } const DEFAULT_TIMEOUT_MS = 60_000; const MAX_TIMEOUT_MS = 5 * 60_000; const MAX_PATH_ARGS = 50; const MAX_OUTPUT_BYTES = 64 * 1024; const REF_NAME_RE = /^[A-Za-z0-9._/-]+$/; const REMOTE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; /** * Strict repo/package name pattern: lowercase letters, digits, and hyphens. * Configurable via environment variable so the policy can be broadened later. */ const REPO_NAME_RE = /^[a-z0-9][a-z0-9-]*$/; const TOOLS_DIR = path.resolve(os.homedir(), "tools"); function validateRepoName(name: string): string { const trimmed = name.trim(); if (!trimmed) { throw new Error("name cannot be empty."); } if (trimmed.length > 100) { throw new Error("name is too long (max 100 characters)."); } if (!REPO_NAME_RE.test(trimmed)) { throw new Error( `name contains unsupported characters. Use lowercase letters, digits, and hyphens only: ${trimmed}`, ); } return trimmed; } function expandHomePath(inputPath: string): string { if (inputPath === "~") { return os.homedir(); } if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) { return path.join(os.homedir(), inputPath.slice(2)); } return inputPath; } function resolveAbsolutePathInput(inputPath: string, label: string): string { const normalized = normalizeFreeText(inputPath, label); if (!normalized) { throw new Error(`${label} cannot be empty.`); } const expanded = expandHomePath(normalized); if (!path.isAbsolute(expanded)) { throw new Error( `${label} must be an absolute path or start with ~/tools: ${inputPath}`, ); } return path.resolve(expanded); } function resolveNearestExistingAncestor(targetPath: string): string { let current = targetPath; while (!fs.existsSync(current)) { const parent = path.dirname(current); if (parent === current) { return current; } current = parent; } return current; } function resolveToolsPath( inputPath: string, label: string, options: { allowToolsRoot?: boolean; mustExist?: boolean } = {}, ): string { const { allowToolsRoot = false, mustExist = false } = options; const resolved = resolveAbsolutePathInput(inputPath, label); const toolsDirForCheck = fs.existsSync(TOOLS_DIR) ? fs.realpathSync(TOOLS_DIR) : TOOLS_DIR; const existingAncestor = resolveNearestExistingAncestor(resolved); const ancestorForCheck = fs.existsSync(existingAncestor) ? fs.realpathSync(existingAncestor) : existingAncestor; const candidateForCheck = existingAncestor === resolved ? ancestorForCheck : path.resolve( ancestorForCheck, path.relative(existingAncestor, resolved), ); const rel = path.relative(toolsDirForCheck, candidateForCheck); if (rel === "") { if (!allowToolsRoot) { throw new Error( `${label} must point to a subdirectory inside ~/tools (${TOOLS_DIR}), not ~/tools itself.`, ); } } else if (rel.startsWith("..") || path.isAbsolute(rel)) { throw new Error( `${label} must be inside ~/tools (${TOOLS_DIR}): ${inputPath}`, ); } if (fs.existsSync(resolved) && !fs.statSync(resolved).isDirectory()) { throw new Error(`${label} must be a directory: ${resolved}`); } if (mustExist && !fs.existsSync(resolved)) { throw new Error(`${label} does not exist: ${resolved}`); } return resolved; } function buildNamedToolsTarget(name: string): string { return path.join(TOOLS_DIR, validateRepoName(name)); } function resolveToolsTarget(name?: string, sourcePath?: string): string { const trimmedName = name?.trim() || undefined; const namedTarget = trimmedName ? buildNamedToolsTarget(trimmedName) : undefined; const resolvedSourcePath = sourcePath ? resolveToolsPath(sourcePath, "sourcePath") : undefined; if (!namedTarget && !resolvedSourcePath) { throw new Error("Either name or sourcePath is required for init."); } if (namedTarget && resolvedSourcePath && resolvedSourcePath !== namedTarget) { throw new Error( `sourcePath (${resolvedSourcePath}) does not match the expected target for name (${namedTarget}). Provide only one.`, ); } return resolvedSourcePath ?? namedTarget!; } function ensureTargetDir(targetPath: string): void { const resolved = resolveToolsPath(targetPath, "targetPath"); fs.mkdirSync(resolved, { recursive: true }); } function clampTimeout(timeoutMs: number | undefined): number { if (!Number.isFinite(timeoutMs)) return DEFAULT_TIMEOUT_MS; return Math.min(Math.max(Number(timeoutMs), 1_000), MAX_TIMEOUT_MS); } function quoteArg(arg: string): string { return /[\s"'\\]/.test(arg) ? JSON.stringify(arg) : arg; } function formatDisplayCommand(executable: string, args: string[]): string { return [path.basename(executable), ...args].map(quoteArg).join(" "); } function isPathInsideWorkspace(targetPath: string, cwd: string): boolean { const rel = path.relative(cwd, targetPath); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); } function resolveWorkspacePath(inputPath: string, cwd: string): string { const resolved = path.resolve(cwd, inputPath); if (!isPathInsideWorkspace(resolved, cwd)) { throw new Error( `Path is outside the workspace and is not allowed: ${inputPath}`, ); } if (!fs.existsSync(resolved)) { throw new Error(`Path does not exist: ${inputPath}`); } return resolved; } function toRelativeArg(targetPath: string, cwd: string): string { const relative = path.relative(cwd, targetPath); return relative === "" ? "." : relative; } function resolveWorkspacePaths( inputPaths: string[] | undefined, cwd: string, ): string[] { const rawPaths = inputPaths && inputPaths.length > 0 ? inputPaths : ["."]; if (rawPaths.length > MAX_PATH_ARGS) { throw new Error(`Too many paths provided (max ${MAX_PATH_ARGS}).`); } const deduped = new Set(); for (const inputPath of rawPaths) { deduped.add(resolveWorkspacePath(inputPath, cwd)); } return Array.from(deduped) .sort() .map((resolvedPath) => toRelativeArg(resolvedPath, cwd)); } function resolveProjectFile(inputPath: string, cwd: string): string { const resolved = resolveWorkspacePath(inputPath, cwd); if (!fs.statSync(resolved).isFile()) { throw new Error(`Expected a file path: ${inputPath}`); } return toRelativeArg(resolved, cwd); } function normalizeRefLike( value: string | undefined, label: string, ): string | undefined { const normalized = normalizeFreeText(value, label); if (normalized === undefined) return undefined; if (!REF_NAME_RE.test(normalized)) { throw new Error( `${label} contains unsupported characters. Use a simple ref-like name.`, ); } return normalized; } function normalizeRemoteName( value: string | undefined, label: string, ): string | undefined { const normalized = normalizeFreeText(value, label); if (normalized === undefined) return undefined; if (!REMOTE_NAME_RE.test(normalized)) { throw new Error( `${label} contains unsupported characters. Use letters, digits, dots, underscores, and hyphens only.`, ); } return normalized; } function normalizeVisibility( value: GhSafeParams["visibility"] | string | undefined, ): "public" | "private" { if (value === undefined) { return "private"; } if (value === "public" || value === "private") { return value; } throw new Error("visibility must be either 'public' or 'private'."); } function normalizePrNumber( value: string | undefined, label: string, ): number | undefined { const normalized = normalizeFreeText(value, label); if (normalized === undefined) return undefined; const num = Number.parseInt(normalized, 10); if (!Number.isFinite(num) || num <= 0 || String(num) !== normalized) { throw new Error(`${label} must be a positive integer: ${value}`); } return num; } function normalizeFreeText( value: string | undefined, label: string, ): string | undefined { if (value === undefined) return undefined; const trimmed = value.trim(); if (!trimmed) { throw new Error(`${label} cannot be empty.`); } if (trimmed.length > 200) { throw new Error(`${label} is too long (max 200 characters).`); } return trimmed; } function candidateExecutableNames(name: string): string[] { if (process.platform === "win32") { return [name, `${name}.cmd`, `${name}.exe`, `${name}.bat`]; } return [name]; } function candidateExecutablePaths(name: string, cwd: string): string[] { const candidates: string[] = []; for (const candidateName of candidateExecutableNames(name)) { candidates.push(path.join(cwd, "node_modules", ".bin", candidateName)); const pathEntries = (process.env.PATH ?? "") .split(path.delimiter) .filter(Boolean); for (const entry of pathEntries) { candidates.push(path.join(entry, candidateName)); } } return candidates; } function resolveExecutable(name: string, cwd: string): string { for (const candidate of candidateExecutablePaths(name, cwd)) { if (fs.existsSync(candidate)) { return candidate; } } throw new Error( `Required executable not found for ${name}. Install it or add it to PATH.`, ); } export function buildSafeCommandPlan( toolName: SafeRunnerName, params: SafeRunnerParams, cwd: string, ): SafeCommandPlan { switch (toolName) { case "run_biome": { const biomeParams = params as RunBiomeParams; const executable = resolveExecutable("biome", cwd); const args = ["check", ...resolveWorkspacePaths(biomeParams.paths, cwd)]; return { toolName, executable, args, cwd, displayCommand: formatDisplayCommand(executable, args), timeoutMs: clampTimeout(biomeParams.timeoutMs), }; } case "run_vitest": { const vitestParams = params as RunVitestParams; const executable = resolveExecutable("vitest", cwd); const args = ["run"]; const testNamePattern = normalizeFreeText( vitestParams.testNamePattern, "testNamePattern", ); if (testNamePattern) { args.push("--testNamePattern", testNamePattern); } if (vitestParams.paths && vitestParams.paths.length > 0) { args.push(...resolveWorkspacePaths(vitestParams.paths, cwd)); } return { toolName, executable, args, cwd, displayCommand: formatDisplayCommand(executable, args), timeoutMs: clampTimeout(vitestParams.timeoutMs), }; } case "run_typecheck": { const typecheckParams = params as RunTypecheckParams; const executable = resolveExecutable("tsc", cwd); const args = ["--noEmit"]; if (typecheckParams.project) { args.push("-p", resolveProjectFile(typecheckParams.project, cwd)); } return { toolName, executable, args, cwd, displayCommand: formatDisplayCommand(executable, args), timeoutMs: clampTimeout(typecheckParams.timeoutMs), }; } case "run_pytest": { const pytestParams = params as RunPytestParams; const executable = resolveExecutable("pytest", cwd); const args: string[] = []; const keyword = normalizeFreeText(pytestParams.keyword, "keyword"); if (keyword) { args.push("-k", keyword); } if (pytestParams.paths && pytestParams.paths.length > 0) { args.push(...resolveWorkspacePaths(pytestParams.paths, cwd)); } return { toolName, executable, args, cwd, displayCommand: formatDisplayCommand(executable, args), timeoutMs: clampTimeout(pytestParams.timeoutMs), }; } case "run_cargo_test": { const cargoParams = params as RunCargoTestParams; const executable = resolveExecutable("cargo", cwd); const args = ["test"]; const packageName = normalizeFreeText(cargoParams.package, "package"); const testNamePattern = normalizeFreeText( cargoParams.testNamePattern, "testNamePattern", ); if (packageName) { args.push("-p", packageName); } if (testNamePattern) { args.push(testNamePattern); } return { toolName, executable, args, cwd, displayCommand: formatDisplayCommand(executable, args), timeoutMs: clampTimeout(cargoParams.timeoutMs), }; } case "git_safe": { const gitParams = params as GitSafeParams; const executable = resolveExecutable("git", cwd); const args: string[] = []; switch (gitParams.action) { case "status": { args.push("status", "--short"); break; } case "diff": { args.push("diff"); if (gitParams.cached) { args.push("--cached"); } if (gitParams.paths && gitParams.paths.length > 0) { args.push("--", ...resolveWorkspacePaths(gitParams.paths, cwd)); } break; } case "add": { const paths = resolveWorkspacePaths(gitParams.paths, cwd); args.push("add", "--", ...paths); break; } case "commit": { const message = normalizeFreeText(gitParams.message, "message"); if (!message) { throw new Error("message is required for git commit."); } args.push("commit", "--message", message); break; } case "push": { args.push("push"); const remote = normalizeRemoteName(gitParams.remote, "remote"); const branch = normalizeRefLike(gitParams.branch, "branch"); if (remote) { args.push(remote); } if (branch) { args.push(branch); } break; } case "init": { const target = resolveToolsTarget( gitParams.name, gitParams.sourcePath, ); ensureTargetDir(target); args.push("init", target); break; } } return { toolName, executable, args, cwd, displayCommand: sanitizeGitCommandForLogging( formatDisplayCommand(executable, args), ), timeoutMs: clampTimeout(gitParams.timeoutMs), }; } case "gh_safe": { const ghParams = params as GhSafeParams; const executable = resolveExecutable("gh", cwd); const args: string[] = []; switch (ghParams.action) { case "repo_create": { const repoName = validateRepoName(ghParams.name ?? ""); const visibility = normalizeVisibility(ghParams.visibility); const sourceDir = ghParams.sourcePath ? resolveToolsPath(ghParams.sourcePath, "sourcePath", { mustExist: true, }) : resolveToolsPath(buildNamedToolsTarget(repoName), "sourcePath", { mustExist: true, }); const remote = normalizeRemoteName(ghParams.remote, "remote") ?? "origin"; args.push( "repo", "create", repoName, visibility === "public" ? "--public" : "--private", ); args.push("--source", sourceDir, "--remote", remote); if (ghParams.push) { args.push("--push"); } return { toolName, executable, args, cwd: sourceDir, displayCommand: formatDisplayCommand(executable, args), timeoutMs: clampTimeout(ghParams.timeoutMs), }; } case "pr_create": { const title = normalizeFreeText(ghParams.title, "title"); if (!title) { throw new Error("title is required for gh pr create."); } args.push("pr", "create", "--title", title); const body = normalizeFreeText(ghParams.body, "body"); if (body) { args.push("--body", body); } const base = normalizeRefLike(ghParams.base, "base"); if (base) { args.push("--base", base); } if (ghParams.draft) { args.push("--draft"); } break; } case "pr_edit": { const prNumber = normalizePrNumber(ghParams.name, "name"); if (prNumber === undefined) { throw new Error("name (PR number) is required for gh pr edit."); } args.push("pr", "edit", String(prNumber)); const title = normalizeFreeText(ghParams.title, "title"); if (title) { args.push("--title", title); } const body = normalizeFreeText(ghParams.body, "body"); if (body) { args.push("--body", body); } break; } case "pr_merge": { const prNumber = normalizePrNumber(ghParams.name, "name"); if (prNumber === undefined) { throw new Error("name (PR number) is required for gh pr merge."); } args.push("pr", "merge", String(prNumber)); if (ghParams.squash) { args.push("--squash"); } if (ghParams.deleteBranch) { args.push("--delete-branch"); } break; } case "pr_view": { args.push("pr", "view"); const prNumber = normalizePrNumber(ghParams.name, "name"); if (prNumber !== undefined) { args.push(String(prNumber)); } break; } } return { toolName, executable, args, cwd, displayCommand: formatDisplayCommand(executable, args), timeoutMs: clampTimeout(ghParams.timeoutMs), }; } } } const SANITIZE_RE = /* biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control-char sanitization */ /[\x00-\x08\x0B\x0E-\x1F]/g; const BINARY_THRESHOLD = 0.1; function sanitizeChunk(chunk: string): string { // Replace null bytes and other control characters (except \t \n \r) with � return chunk.replace(SANITIZE_RE, "\uFFFD"); } function _isLikelyBinary(text: string): boolean { if (text.length === 0) return false; let bad = 0; for (let i = 0; i < text.length; i++) { const code = text.charCodeAt(i); if ( code === 0 || (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) ) { bad++; } // Early exit if we've already decided if (bad > text.length * BINARY_THRESHOLD) return true; } return false; } function appendChunk(current: string, chunk: string): string { if (!chunk) return current; const sanitized = sanitizeChunk(chunk); const combined = current + sanitized; if (Buffer.byteLength(combined, "utf8") <= MAX_OUTPUT_BYTES) { return combined; } const truncatedMarker = "\n… output truncated …\n"; const budget = MAX_OUTPUT_BYTES - Buffer.byteLength(truncatedMarker, "utf8"); if (budget <= 0) return truncatedMarker; const tail = combined.slice(-budget); return `${truncatedMarker}${tail}`; } export async function executeSafeCommandPlan( plan: SafeCommandPlan, signal?: AbortSignal, ): Promise { if (signal?.aborted) { return { toolName: plan.toolName, executable: plan.executable, args: plan.args, cwd: plan.cwd, displayCommand: plan.displayCommand, exitCode: null, timedOut: false, aborted: true, spawnError: false, stdout: "", stderr: "", output: "Command aborted before start.", }; } return new Promise((resolve, _reject) => { const child = spawn(plan.executable, plan.args, { cwd: plan.cwd, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; let rawCharCount = 0; let rawControlCount = 0; let finished = false; let timedOut = false; let aborted = false; const countRawChars = (str: string) => { for (let i = 0; i < str.length; i++) { rawCharCount++; const code = str.charCodeAt(i); if ( code === 0 || (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) ) { rawControlCount++; } } }; const finish = (exitCode: number | null) => { if (finished) return; finished = true; clearTimeout(timer); signal?.removeEventListener("abort", onAbort); const combinedOutput = [stdout, stderr].filter(Boolean).join("\n").trim(); const binaryWarning = rawCharCount > 0 && rawControlCount / rawCharCount > BINARY_THRESHOLD ? "⚠ Output appears to contain binary or non-text data; it has been sanitized.\n" : ""; resolve({ toolName: plan.toolName, executable: plan.executable, args: plan.args, cwd: plan.cwd, displayCommand: plan.displayCommand, exitCode, timedOut, aborted, spawnError: false, stdout, stderr, output: binaryWarning + combinedOutput, }); }; const onAbort = () => { if (finished) return; aborted = true; child.kill("SIGKILL"); finish(null); }; const timer = setTimeout(() => { if (finished) return; timedOut = true; child.kill("SIGKILL"); finish(null); }, plan.timeoutMs); signal?.addEventListener("abort", onAbort, { once: true }); child.stdout.on("data", (chunk) => { const str = String(chunk); countRawChars(str); stdout = appendChunk(stdout, str); }); child.stderr.on("data", (chunk) => { const str = String(chunk); countRawChars(str); stderr = appendChunk(stderr, str); }); child.on("error", (error) => { if (finished) return; finished = true; clearTimeout(timer); signal?.removeEventListener("abort", onAbort); // Resolve with a structured result instead of rejecting, so callers // always get a SafeCommandExecutionResult even when spawn fails. resolve({ toolName: plan.toolName, executable: plan.executable, args: plan.args, cwd: plan.cwd, displayCommand: plan.displayCommand, exitCode: null, timedOut: false, aborted: false, spawnError: true, stdout: "", stderr: error.message || "spawn error", output: `Command failed to start: ${error.message || "unknown spawn error"}`, }); }); child.on("close", (code) => { finish(code); }); }); } function isPathExcludedFactory(rules: Rules) { return (filePath: string, repoCwd: string): boolean => { const followSymlinks = rules.symlinkPolicy === "follow"; const resolved = resolvePath(filePath, repoCwd, { followSymlinks }); return rules.secretScanExcludedPaths.some((pattern) => isPathMatch(resolved, pattern, repoCwd, { followSymlinks }), ); }; } function enforceGitSecretSafety(plan: SafeCommandPlan, cwd: string): void { if (plan.toolName !== "git_safe") return; const command = formatDisplayCommand(plan.executable, plan.args); if (!/^git\s+(add|commit|push)\b/.test(command)) return; const loadResult = loadRules(cwd); const secretCompileResult = compileSecretPatternsSafe( loadResult.rules.secretBlockingPatterns, ); const violation = evaluateGitSecretBlock({ command, cwd, secretPatterns: secretCompileResult.compiled, isPathExcluded: isPathExcludedFactory(loadResult.rules), }); if (violation) { throw new Error(violation.reason); } } export async function runSafeCommand( toolName: SafeRunnerName, params: SafeRunnerParams, cwd: string, signal?: AbortSignal, ): Promise { const plan = buildSafeCommandPlan(toolName, params, cwd); enforceGitSecretSafety(plan, cwd); return executeSafeCommandPlan(plan, signal); } export function formatSafeCommandResult( result: SafeCommandExecutionResult, ): string { const lines = [`Command: ${result.displayCommand}`, `CWD: ${result.cwd}`]; if (result.timedOut) { lines.push("Status: timed out"); } else if (result.aborted) { lines.push("Status: aborted"); } else if (result.spawnError) { lines.push("Status: spawn error"); } else { lines.push(`Exit code: ${result.exitCode ?? "null"}`); } lines.push(""); lines.push(result.output || "(no output)"); return lines.join("\n").trim(); }