import Foundation /// Single entry point for spawning the `codeburn` CLI. All callers route through here so the /// binary argv is validated once and no code path ever passes user-influenced strings through /// a shell (`/bin/zsh -c`, `open --args`, AppleScript). This closes the shell-injection attack /// surface end-to-end. enum CodeburnCLI { /// Matches a plain file path / program name: alphanumerics, dot, underscore, slash, hyphen, /// space. Deliberately excludes shell metacharacters (`$`, `;`, `&`, `|`, quotes, backticks, /// newlines) so a malicious `CODEBURN_BIN="codeburn; rm -rf ~"` can't slip through. private static let safeArgPattern = try! NSRegularExpression(pattern: "^[A-Za-z0-9 ._/\\-]+$") /// PATH additions for GUI-launched apps, which otherwise get a minimal PATH that misses /// Homebrew and npm global installs. private static let additionalPathEntries = ["/opt/homebrew/bin", "/usr/local/bin"] /// Returns the argv that launches the CLI. Dev override via `CODEBURN_BIN` is honoured only /// if every whitespace-delimited token passes `safeArgPattern`. Otherwise falls back to the /// plain `codeburn` name (resolved via PATH). static func baseArgv() -> [String] { guard let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"], !raw.isEmpty else { return ["codeburn"] } let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init) guard parts.allSatisfy(isSafe) else { NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using default 'codeburn'") return ["codeburn"] } return parts } /// Builds a `Process` that runs the CLI with the given subcommand args. Uses `/usr/bin/env` /// so PATH lookup happens without involving a shell, and augments PATH with Homebrew /// defaults. Caller sets stdout/stderr pipes and calls `run()`. static func makeProcess(subcommand: [String]) -> Process { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") var environment = ProcessInfo.processInfo.environment environment["PATH"] = augmentedPath(environment["PATH"] ?? "") process.environment = environment // `env --` treats everything following as argv, not VAR=val pairs -- guards against an // argument accidentally resembling an env assignment. process.arguments = ["--"] + baseArgv() + subcommand return process } static func isSafe(_ s: String) -> Bool { let range = NSRange(s.startIndex.. String { var parts = existing.split(separator: ":", omittingEmptySubsequences: true).map(String.init) for extra in additionalPathEntries where !parts.contains(extra) { parts.append(extra) } return parts.joined(separator: ":") } }