Compare commits

..

6 Commits

Author SHA1 Message Date
diegosouzapw
7ad5d42982 release: v3.0.0-rc.12 — merge PRs #542, #544, #546, #555 + TDZ fix + build fixes
Community PRs:
- #546: fix(cli): --version returning unknown on Windows
- #555: fix(sse): centralized resolveDataDir() for path resolution
- #544: fix(cli): secure CLI tool detection via known installation paths
- #542: fix(ui): light mode contrast — missing CSS theme variables

Additional:
- Fix TDZ error in cliRuntime.ts (validateEnvPath before getExpectedParentPaths)
- Add pino/pino-pretty to serverExternalPackages for build stability
- 905 tests passing
2026-03-23 15:11:18 -03:00
diegosouzapw
3912734498 fix: cherry-pick PR #542 (light mode contrast) + fix TDZ in cliRuntime.ts
- Add missing CSS theme variables (bg-primary, bg-subtle, text-primary)
- Fix hardcoded dark-mode-only colors with proper dark: variants
- Fix ReferenceError: move validateEnvPath before getExpectedParentPaths
2026-03-23 15:10:19 -03:00
k0valik
0fa3f9a057 fix: (cli) secure CLI tool detection via known installation paths (Win… (#544)
fix(cli): secure CLI tool detection via known installation paths with security hardening — symlink validation, file-type checks, size bounds, minimal env in healthcheck for 8 CLI tools
2026-03-23 15:04:14 -03:00
k0valik
0fbabdcf25 fix(sse): use centralized resolveDataDir() for path resolution (#555)
fix(sse): use centralized resolveDataDir() for path resolution in credentialLoader, autoCombo persistence, responsesTransformer, and requestLogger
2026-03-23 15:04:03 -03:00
k0valik
67b7ae98a6 fix(cli): resolve --version returning 'unknown' on Windows (#546)
fix(cli): resolve --version returning 'unknown' on Windows by using JSON.parse(readFileSync) instead of ESM import with { type: 'json' }
2026-03-23 15:03:51 -03:00
diegosouzapw
0f703c95dd fix(build): add pino and pino-pretty to serverExternalPackages 2026-03-23 11:19:53 -03:00
14 changed files with 386 additions and 44 deletions

View File

@@ -6,6 +6,27 @@
---
## [3.0.0-rc.12] — 2026-03-23
### 🔀 Community PRs Merged
| PR | Author | Summary |
| -------- | -------- | --------------------------------------------------------------------------------- |
| **#546** | @k0valik | fix(cli): `--version` returning `unknown` on Windows — use `JSON.parse(readFileSync)` instead of ESM import |
| **#555** | @k0valik | fix(sse): centralized `resolveDataDir()` for path resolution in credentials, autoCombo, responses logger, and request logger |
| **#544** | @k0valik | fix(cli): secure CLI tool detection via known installation paths (8 tools) with symlink validation, file-type checks, size bounds, minimal env in healthcheck |
| **#542** | @rdself | fix(ui): improve light mode contrast — add missing CSS theme variables (`bg-primary`, `bg-subtle`, `text-primary`) and fix dark-only colors in log detail |
### 🔧 Bug Fixes
- **TDZ fix in `cliRuntime.ts`** — `validateEnvPath` was used before initialization at module startup by `getExpectedParentPaths()`. Reordered declarations to fix `ReferenceError`.
- **Build fixes** — Added `pino` and `pino-pretty` to `serverExternalPackages` to prevent Turbopack from breaking Pino's internal worker loading.
### 🧪 Tests
- Test suite: **905 tests, 0 failures**
---
## [3.0.0-rc.10] — 2026-03-23
### 🔧 Bug Fixes

View File

@@ -116,10 +116,8 @@ if (args.includes("--help") || args.includes("-h")) {
if (args.includes("--version") || args.includes("-v")) {
try {
const pkg = await import(join(ROOT, "package.json"), {
with: { type: "json" },
});
console.log(pkg.default.version);
const { version } = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
console.log(version);
} catch {
console.log("unknown");
}

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.0.0-rc.10
version: 3.0.0-rc.12
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,

View File

@@ -13,6 +13,8 @@ const nextConfig = {
},
output: "standalone",
serverExternalPackages: [
"pino",
"pino-pretty",
"thread-stream",
"better-sqlite3",
"zod",

View File

@@ -16,6 +16,7 @@
import { readFileSync, existsSync } from "fs";
import { join } from "path";
import { resolveDataDir } from "../../src/lib/dataPaths";
// Fields that can be overridden per provider
const CREDENTIAL_FIELDS = ["clientId", "clientSecret", "tokenUrl", "authUrl", "refreshUrl"];
@@ -30,8 +31,7 @@ let cachedProviders = null;
* Priority: DATA_DIR env → ./data (project root)
*/
function resolveCredentialsPath() {
const dataDir = process.env.DATA_DIR || join(process.cwd(), "data");
return join(dataDir, "provider-credentials.json");
return join(resolveDataDir(), "provider-credentials.json");
}
/**
@@ -93,7 +93,11 @@ export function loadProviderCredentials(providers) {
`[CREDENTIALS] ${isReload ? "Reloaded" : "Loaded"} external credentials: ${overrideCount} field(s) from ${credPath}`
);
} catch (err) {
console.log(`[CREDENTIALS] Error reading credentials file: ${err.message}. Using defaults.`);
const reason =
err instanceof SyntaxError
? "Invalid JSON format"
: (err as NodeJS.ErrnoException).code || "read error";
console.log(`[CREDENTIALS] Error reading credentials file (${reason}). Using defaults.`);
}
cachedProviders = providers;

View File

@@ -7,6 +7,7 @@
import fs from "fs";
import path from "path";
import { resolveDataDir } from "../../../src/lib/dataPaths";
export interface AdaptationState {
comboId: string;
@@ -23,7 +24,7 @@ export interface AdaptationState {
lastUpdated: string;
}
const PERSISTENCE_DIR = path.join(process.cwd(), "data");
const PERSISTENCE_DIR = resolveDataDir();
const STATE_FILE = path.join(PERSISTENCE_DIR, "auto_combo_state.json");
let stateCache = new Map<string, AdaptationState>();

View File

@@ -1,5 +1,6 @@
import * as fs from "fs";
import * as path from "path";
import { resolveDataDir } from "../../src/lib/dataPaths";
/**
* Responses API Transformer
* Converts OpenAI Chat Completions SSE to Codex Responses API SSE format
@@ -39,7 +40,7 @@ export function createResponsesLogger(model, logsDir = null) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
const uniqueId = Math.random().toString(36).slice(2, 8);
const baseDir = logsDir || (typeof process !== "undefined" ? process.cwd() : ".");
const baseDir = logsDir || resolveDataDir();
const logDir = path.join(baseDir, "logs", `responses_${model}_${timestamp}_${uniqueId}`);
try {

View File

@@ -16,10 +16,8 @@ async function ensureNodeModules() {
try {
fs = await import("fs");
path = await import("path");
LOGS_DIR = path.join(
typeof process !== "undefined" && process.cwd ? process.cwd() : ".",
"logs"
);
const { resolveDataDir } = await import("../../src/lib/dataPaths");
LOGS_DIR = path.join(resolveDataDir(), "logs");
} catch {
// Running in non-Node environment (Worker, Browser, etc.)
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.0.0-rc.10",
"version": "3.0.0-rc.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.0.0-rc.10",
"version": "3.0.0-rc.12",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.0.0-rc.10",
"version": "3.0.0-rc.12",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {

View File

@@ -36,10 +36,13 @@
/* Light theme */
--color-bg: #f9f9fb;
--color-bg-alt: #f0f0f5;
--color-bg-primary: #f9f9fb;
--color-bg-subtle: #f0f0f5;
--color-surface: #ffffff;
--color-sidebar: rgba(245, 245, 250, 0.8);
--color-border: rgba(0, 0, 0, 0.08);
--color-text-main: #1a1a2e;
--color-text-primary: #1a1a2e;
--color-text-muted: #71717a;
/* Shadows */
@@ -52,10 +55,13 @@
/* Dark theme (ClawHub deep) */
--color-bg: #0b0e14;
--color-bg-alt: #111520;
--color-bg-primary: #0b0e14;
--color-bg-subtle: #111520;
--color-surface: #161b22;
--color-sidebar: rgba(16, 20, 30, 0.8);
--color-border: rgba(255, 255, 255, 0.08);
--color-text-main: #e6e6ef;
--color-text-primary: #e6e6ef;
--color-text-muted: #a1a1aa;
/* Dark shadows */
@@ -81,10 +87,13 @@
/* Auto-switch colors (use CSS variables from :root/.dark) */
--color-bg: var(--color-bg);
--color-bg-primary: var(--color-bg-primary);
--color-bg-subtle: var(--color-bg-subtle);
--color-surface: var(--color-surface);
--color-sidebar: var(--color-sidebar);
--color-border: var(--color-border);
--color-text-main: var(--color-text-main);
--color-text-primary: var(--color-text-primary);
--color-text-muted: var(--color-text-muted);
/* Static colors (for explicit light/dark usage) */

View File

@@ -36,7 +36,7 @@ function PayloadSection({ title, json, onCopy }) {
{copied ? "Copied!" : "Copy"}
</button>
</div>
<pre className="p-4 rounded-xl bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-primary max-h-[600px] overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
<pre className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-[600px] overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
{json}
</pre>
</div>
@@ -138,7 +138,7 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
<span className="px-2 py-0.5 rounded bg-primary/20 text-primary text-xs font-bold">
In: {(detail?.tokens?.in || log.tokens?.in || 0).toLocaleString()}
</span>
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400 text-xs font-bold">
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold">
Out: {(detail?.tokens?.out || log.tokens?.out || 0).toLocaleString()}
</span>
</div>
@@ -213,7 +213,7 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Combo</div>
{detail?.comboName || log.comboName ? (
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-bold bg-violet-500/20 text-violet-300 border border-violet-500/30">
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-bold bg-violet-500/20 text-violet-700 dark:text-violet-300 border border-violet-500/30">
{detail?.comboName || log.comboName}
</span>
) : (
@@ -225,10 +225,12 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
{/* Error Message */}
{(detail?.error || log.error) && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30">
<div className="text-[10px] text-red-400 uppercase tracking-wider mb-1 font-bold">
<div className="text-[10px] text-red-600 dark:text-red-400 uppercase tracking-wider mb-1 font-bold">
Error
</div>
<div className="text-sm text-red-300 font-mono">{detail?.error || log.error}</div>
<div className="text-sm text-red-600 dark:text-red-300 font-mono">
{detail?.error || log.error}
</div>
</div>
)}

View File

@@ -16,6 +16,7 @@ export const CLI_TOOLS = {
},
modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"],
settingsFile: "~/.claude/settings.json",
defaultCommand: "claude",
defaultModels: [
{
id: "opus",
@@ -47,6 +48,7 @@ export const CLI_TOOLS = {
color: "#10A37F",
description: "OpenAI Codex CLI",
configType: "custom",
defaultCommand: "codex",
},
droid: {
id: "droid",
@@ -55,6 +57,7 @@ export const CLI_TOOLS = {
color: "#00D4FF",
description: "Factory Droid AI Assistant",
configType: "custom",
defaultCommand: "droid",
},
openclaw: {
id: "openclaw",
@@ -63,6 +66,7 @@ export const CLI_TOOLS = {
color: "#FF6B35",
description: "Open Claw AI Assistant",
configType: "custom",
defaultCommand: "openclaw",
},
cursor: {
id: "cursor",
@@ -72,6 +76,7 @@ export const CLI_TOOLS = {
description: "Cursor AI Code Editor",
configType: "guide",
requiresCloud: true,
defaultCommands: ["agent", "cursor"],
notes: [
{ type: "warning", text: "Requires Cursor Pro account to use this feature." },
{
@@ -95,6 +100,7 @@ export const CLI_TOOLS = {
color: "#00D1B2",
description: "Cline AI Coding Assistant CLI",
configType: "custom",
defaultCommand: "cline",
},
kilo: {
id: "kilo",
@@ -103,6 +109,7 @@ export const CLI_TOOLS = {
color: "#FF6B6B",
description: "Kilo Code AI Assistant CLI",
configType: "custom",
defaultCommand: "kilocode",
},
continue: {
id: "continue",
@@ -180,6 +187,7 @@ export const CLI_TOOLS = {
color: "#FF6B35",
description: "OpenCode AI coding agent (Terminal)",
configType: "guide",
defaultCommand: "opencode",
notes: [
{
type: "warning",

View File

@@ -197,15 +197,220 @@ const getRuntimeMode = () => {
return VALID_RUNTIME_MODES.has(mode) ? mode : "auto";
};
/**
* T12: Validate a CLI executable path to prevent shell injection.
* Enforces: absolute path, no dangerous shell metacharacters, must exist and be a file.
* Inspired by Antigravity Manager commit 96732c2 (Mar 11, 2026).
*/
const DANGEROUS_PATH_CHARS = ["&", "|", ";", "<", ">", "(", ")", "`", "$", "^", "%", "!"];
/**
* Check if a path is within a parent directory (case-insensitive, handles mixed separators).
* Normalizes both paths to forward slashes before comparison to handle
* inconsistent separator styles on Windows.
*/
const isPathWithin = (childPath: string, parentPath: string): boolean => {
// Normalize to forward slashes for consistent comparison
const normalize = (p: string) => path.normalize(p).toLowerCase().replace(/\\/g, "/");
const normalizedChild = normalize(childPath);
const normalizedParent = normalize(parentPath);
if (normalizedChild === normalizedParent) return true;
// Ensure parent ends with / for proper prefix matching
const parentWithSep = normalizedParent.endsWith("/") ? normalizedParent : normalizedParent + "/";
return normalizedChild.startsWith(parentWithSep);
};
const isSafePath = (execPath: string): boolean => {
if (!execPath || !path.isAbsolute(execPath)) return false;
if (DANGEROUS_PATH_CHARS.some((c) => execPath.includes(c))) return false;
// Allow path.sep and path.delimiter — no further character filtering needed
return true;
};
/**
* Validate that an environment variable value is a safe, absolute path
* within acceptable directory trees. Rejects traversal, special chars,
* and paths outside expected locations.
*/
const validateEnvPath = (value: string | undefined, allowedParents: string[]): string => {
if (!value) return "";
const trimmed = value.trim();
// Reject if not absolute
if (!path.isAbsolute(trimmed)) return "";
// Reject dangerous characters (same as isSafePath but applied to env vars)
if (DANGEROUS_PATH_CHARS.some((c) => trimmed.includes(c))) return "";
// Reject if contains path traversal segments
const normalized = path.normalize(trimmed);
if (normalized.includes("..")) return "";
// Reject if outside allowed parent directories
if (allowedParents.length > 0) {
const withinAllowed = allowedParents.some((parent) => isPathWithin(normalized, parent));
if (!withinAllowed) return "";
}
return normalized;
};
/**
* Pre-compute expected parent directories at module startup for performance.
* These are the allowed directories for CLI binary installation locations.
*/
const getExpectedParentPaths = (): string[] => {
const home = os.homedir();
const userProfile = process.env.USERPROFILE || home;
const validatedAppData = validateEnvPath(process.env.APPDATA, [home, userProfile]);
const validatedLocalAppData = validateEnvPath(process.env.LOCALAPPDATA, [
path.join(home, "AppData", "Local"),
path.join(userProfile, "AppData", "Local"),
userProfile,
]);
const validatedProgramFiles = validateEnvPath(process.env.ProgramFiles, [
"C:\\Program Files",
"C:\\Program Files (x86)",
]);
const validatedProgramFilesX86 = validateEnvPath(process.env["ProgramFiles(x86)"], [
"C:\\Program Files",
"C:\\Program Files (x86)",
]);
return [
home,
userProfile,
validatedAppData,
validatedLocalAppData,
validatedProgramFiles,
validatedProgramFilesX86,
].filter(Boolean);
};
// Cache expected parent paths at module startup (avoid recalculation on every checkKnownPath call)
const EXPECTED_PARENT_PATHS = getExpectedParentPaths();
const getExtraPaths = () =>
String(process.env.CLI_EXTRA_PATHS || "")
.split(path.delimiter)
.map((segment) => segment.trim())
.filter(Boolean);
.filter(Boolean)
.filter((p) => {
// Must be absolute
if (!path.isAbsolute(p)) return false;
// No dangerous characters
if (DANGEROUS_PATH_CHARS.some((c) => p.includes(c))) return false;
// No path traversal
if (path.normalize(p).includes("..")) return false;
return true;
});
/**
* Get known installation paths for a specific CLI tool on Windows.
* Returns ONLY verified, tool-specific paths - NOT generic user bin directories.
* This is more secure than searching PATH as it checks known locations only.
*/
const getKnownToolPaths = (toolId: string): string[] => {
if (!isWindows()) return [];
const home = os.homedir();
const userProfile = process.env.USERPROFILE || home;
// Validate environment paths against allowed parent directories
const appData = validateEnvPath(process.env.APPDATA, [home, userProfile]);
const localAppData = validateEnvPath(process.env.LOCALAPPDATA, [
path.join(home, "AppData", "Local"),
path.join(userProfile, "AppData", "Local"),
userProfile,
]);
// Cache nvm node path to avoid duplicate detection calls
const nvmNodePath = getNvmNodePath();
// Tool-specific known installation paths (verified locations only)
const knownPaths: Record<string, string[]> = {
claude: [
// Official Claude Code standalone installer locations
path.join(home, ".local", "bin", "claude.exe"),
...(localAppData ? [path.join(localAppData, "Programs", "Claude", "claude.exe")] : []),
...(localAppData ? [path.join(localAppData, "claude-code", "claude.exe")] : []),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "claude-code.cmd")] : []),
],
codex: [
path.join(home, ".local", "bin", "codex"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "codex.cmd")] : []),
...(appData ? [path.join(appData, "npm", "codex.cmd")] : []),
],
droid: [
path.join(home, ".local", "bin", "droid"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "droid.cmd")] : []),
...(appData ? [path.join(appData, "npm", "droid.cmd")] : []),
],
openclaw: [
path.join(home, ".local", "bin", "openclaw"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "openclaw.cmd")] : []),
...(appData ? [path.join(appData, "npm", "openclaw.cmd")] : []),
],
cursor: [
path.join(home, ".local", "bin", "agent"),
path.join(home, ".local", "bin", "cursor"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "agent.cmd")] : []),
...(nvmNodePath ? [path.join(nvmNodePath, "cursor.cmd")] : []),
...(appData ? [path.join(appData, "npm", "agent.cmd")] : []),
...(appData ? [path.join(appData, "npm", "cursor.cmd")] : []),
],
cline: [
path.join(home, ".local", "bin", "cline"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "cline.cmd")] : []),
...(appData ? [path.join(appData, "npm", "cline.cmd")] : []),
],
kilo: [
path.join(home, ".local", "bin", "kilocode"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "kilocode.cmd")] : []),
...(appData ? [path.join(appData, "npm", "kilocode.cmd")] : []),
],
opencode: [
path.join(home, ".local", "bin", "opencode"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "opencode.cmd")] : []),
...(appData ? [path.join(appData, "npm", "opencode.cmd")] : []),
],
// Add other tools as needed with their specific known paths
};
return knownPaths[toolId] || [];
};
/**
* Detect nvm-windows installation path dynamically from current Node.js executable.
* Returns the directory containing node.exe if nvm is detected, null otherwise.
*/
const getNvmNodePath = (): string | null => {
// Simple heuristic: if process.execPath includes "nvm", use its directory
if (process.execPath.toLowerCase().includes("nvm")) {
return path.dirname(process.execPath);
}
return null;
};
const getLookupEnv = () => {
const env = { ...process.env };
const extraPaths = getExtraPaths();
// Only add user-specified extra paths, NOT generic user directories
// This is more secure - user explicitly opts in via CLI_EXTRA_PATHS
if (extraPaths.length > 0) {
env.PATH = [...extraPaths, env.PATH || ""].filter(Boolean).join(path.delimiter);
}
@@ -223,20 +428,6 @@ const resolveToolCommands = (toolId: string): string[] => {
return tool.defaultCommand ? [tool.defaultCommand] : [];
};
/**
* T12: Validate a CLI executable path to prevent shell injection.
* Enforces: absolute path, no dangerous shell metacharacters, must exist and be a file.
* Inspired by Antigravity Manager commit 96732c2 (Mar 11, 2026).
*/
const DANGEROUS_PATH_CHARS = ["&", "|", ";", "<", ">", "(", ")", "`", "$", "^", "%", "!"];
const isSafePath = (execPath: string): boolean => {
if (!execPath || !path.isAbsolute(execPath)) return false;
if (DANGEROUS_PATH_CHARS.some((c) => execPath.includes(c))) return false;
// Allow path.sep and path.delimiter — no further character filtering needed
return true;
};
const checkExplicitPath = async (commandPath: string) => {
// Reject paths that look like injection attempts
if (!isSafePath(commandPath)) {
@@ -294,14 +485,93 @@ const locateCommand = async (command: string, env: Record<string, string | undef
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
};
/**
* Check if a command exists at a specific absolute path.
* Used for known installation locations.
*
* Security hardening:
* - Resolves symlinks and verifies target stays within expected directories
* - Verifies file is a regular file (not directory, pipe, or device)
* - Checks file size bounds (1KB - 100MB) to detect suspicious binaries
*/
const checkKnownPath = async (commandPath: string) => {
if (!path.isAbsolute(commandPath)) {
return { installed: false, commandPath: null, reason: "not_absolute" };
}
if (!isSafePath(commandPath)) {
return { installed: false, commandPath: null, reason: "unsafe_path" };
}
try {
// Resolve symlinks to get the real path and detect symlink escapes
const realPath = await fs.realpath(commandPath);
// Verify the resolved path is still within expected directories
// Use pre-computed expected parent paths (cached at module startup for performance)
const isWithinExpected = EXPECTED_PARENT_PATHS.some((parent) => isPathWithin(realPath, parent));
if (!isWithinExpected) {
return { installed: false, commandPath: null, reason: "symlink_escape" };
}
// Verify it's a regular file with reasonable size
const stat = await fs.stat(realPath);
if (!stat.isFile()) {
return { installed: false, commandPath: null, reason: "not_file" };
}
// CLI binaries should be > 1KB and < 100MB
// This catches suspicious files while allowing for wrapper scripts
if (stat.size < 1024 || stat.size > 100 * 1024 * 1024) {
return { installed: false, commandPath: null, reason: "suspicious_size" };
}
} catch (error) {
const errorCode = (error as NodeJS.ErrnoException).code;
if (errorCode === "ENOENT") {
return { installed: false, commandPath: null, reason: "not_found" };
}
if (errorCode === "EINVAL") {
return { installed: false, commandPath: null, reason: "invalid_path" };
}
return { installed: false, commandPath: null, reason: "access_error" };
}
try {
await fs.access(commandPath, fs.constants.X_OK);
return { installed: true, commandPath, reason: null };
} catch {
return { installed: true, commandPath, reason: "not_executable" };
}
};
const locateCommandCandidate = async (
commands: string[],
env: Record<string, string | undefined>
env: Record<string, string | undefined>,
toolId?: string
) => {
if (!Array.isArray(commands) || commands.length === 0) {
return { command: null, installed: false, commandPath: null, reason: "missing_command" };
}
// SECURITY: First check known installation paths for this specific tool
// This avoids searching PATH and reduces attack surface
if (toolId && isWindows()) {
const knownPaths = getKnownToolPaths(toolId);
for (const knownPath of knownPaths) {
const result = await checkKnownPath(knownPath);
if (result.installed && result.reason === null) {
return {
command: commands[0],
installed: true,
commandPath: result.commandPath,
reason: null,
};
}
}
}
// Fallback: search PATH (user can set CLI_EXTRA_PATHS if needed)
for (const command of commands) {
const located = await locateCommand(command, env);
if (located.installed || located.reason !== "not_found") {
@@ -317,10 +587,18 @@ const checkRunnable = async (
env: Record<string, string | undefined>,
timeoutMs = 4000
) => {
// Minimal environment to prevent credential leakage to potentially malicious binaries
const minimalEnv: Record<string, string | undefined> = {
PATH: env.PATH,
HOME: env.HOME || env.USERPROFILE,
SystemRoot: env.SystemRoot, // Windows needs this
};
for (const args of [["--version"], ["-v"]]) {
const result = await runProcess(commandPath, args, { env, timeoutMs });
if (result.ok) {
return { runnable: true, reason: null };
const result = await runProcess(commandPath, args, { env: minimalEnv, timeoutMs });
// Validate output: must be non-empty and reasonable length (< 4KB)
if (result.ok && result.stdout.length > 0 && result.stdout.length < 4096) {
return { runnable: true, reason: null, version: result.stdout.trim() };
}
}
return { runnable: false, reason: "healthcheck_failed" };
@@ -334,8 +612,28 @@ export const ensureCliConfigWriteAllowed = () => {
return "CLI config writes are disabled (CLI_ALLOW_CONFIG_WRITES=false)";
};
export const getCliConfigHome = () =>
String(process.env.CLI_CONFIG_HOME || "").trim() || os.homedir();
export const getCliConfigHome = () => {
const override = String(process.env.CLI_CONFIG_HOME || "").trim();
if (!override) return os.homedir();
// Must be absolute
if (!path.isAbsolute(override)) return os.homedir();
// Must not contain dangerous characters
if (DANGEROUS_PATH_CHARS.some((c) => override.includes(c))) return os.homedir();
// Must not contain path traversal
if (path.normalize(override).includes("..")) return os.homedir();
// Must be within user's home directory (prevent reading from system dirs)
const home = os.homedir();
const normalized = path.normalize(override);
if (!isPathWithin(normalized, home)) {
return home; // Silently fall back to home
}
return normalized;
};
export const resolveOpencodeConfigDir = (
platform = process.platform,
@@ -417,7 +715,7 @@ export const getCliRuntimeStatus = async (toolId: string) => {
};
}
const located = await locateCommandCandidate(commands, env);
const located = await locateCommandCandidate(commands, env, toolId);
const command = located.command;
if (!located.installed) {