Files
OmniRoute/src/lib/acp/registry.ts
Diego Rodrigues de Sa e Souza 60829241fd fix(security): close 4 STILL-REAL advisory findings (ACP RCE hardening, db-backups tier, uppercase authz bypass, spawn-veto drift) (#11028)
5 — 4 achados STILL-REAL de advisories de segurança, cada um com TDD (RED→GREEN) e crédito ao reporter original: ACP RCE hardening (resolveVersionProbe), db-backups Tier-2 allowlist, uppercase authz bypass (matcher case-insensitive), spawn-veto drift (chatgpt-web-codex-doctor). typecheck/lint limpos, suíte authz/acp/cors verde. UNSTABLE é o base-red inherited #9985, já documentado no corpo da PR.
2026-08-21 13:57:42 -03:00

435 lines
11 KiB
TypeScript

/**
* ACP (Agent Client Protocol) — CLI Agent Registry
*
* Discovers installed CLI tools on the system by checking standard paths
* and running version commands. Used to offer ACP transport as an alternative
* to the HTTP proxy method.
*
* Supports built-in agents + user-defined custom agents from settings.
*
* Reference: https://github.com/iOfficeAI/AionUi (auto-detects CLI agents)
*/
import { execFileSync } from "child_process";
import path from "path";
export interface CliAgentInfo {
/** Agent identifier (e.g., "codex", "claude", "goose") */
id: string;
/** Display name */
name: string;
/** Binary name to spawn */
binary: string;
/** Version detection command */
versionCommand: string;
/** Detected version (null if not installed) */
version: string | null;
/** Whether the agent is installed and available */
installed: boolean;
/** Provider ID that this agent maps to in OmniRoute */
providerAlias: string;
/** Arguments to pass when spawning for ACP */
spawnArgs: string[];
/** Protocol used for communication */
protocol: "stdio" | "http";
/** Whether this is a user-defined custom agent */
isCustom?: boolean;
}
/** Shape stored in settings DB for custom agents */
export interface CustomAgentDef {
id: string;
name: string;
binary: string;
versionCommand: string;
providerAlias: string;
spawnArgs: string[];
protocol: "stdio" | "http";
}
/**
* Registry of known CLI agents that support ACP or similar protocols.
*/
const AGENT_DEFINITIONS: Omit<CliAgentInfo, "version" | "installed">[] = [
{
id: "codex",
name: "OpenAI Codex CLI",
binary: "codex",
versionCommand: "codex --version",
providerAlias: "codex",
spawnArgs: ["--quiet"],
protocol: "stdio",
},
{
id: "claude",
name: "Claude Code CLI",
binary: "claude",
versionCommand: "claude --version",
providerAlias: "claude",
spawnArgs: ["--print", "--output-format", "json"],
protocol: "stdio",
},
{
id: "gemini",
name: "Google Gemini CLI",
binary: "gemini",
versionCommand: "gemini --version",
providerAlias: "gemini",
spawnArgs: [],
protocol: "stdio",
},
{
id: "goose",
name: "Goose CLI",
binary: "goose",
versionCommand: "goose --version",
providerAlias: "goose",
spawnArgs: [],
protocol: "stdio",
},
{
id: "openclaw",
name: "OpenClaw",
binary: "openclaw",
versionCommand: "openclaw --version",
providerAlias: "openclaw",
spawnArgs: [],
protocol: "stdio",
},
{
id: "aider",
name: "Aider",
binary: "aider",
versionCommand: "aider --version",
providerAlias: "aider",
spawnArgs: ["--no-auto-commits"],
protocol: "stdio",
},
{
id: "zcode",
name: "ZCode (GLM Coding Plan)",
binary: "zcode",
versionCommand: "zcode --version",
providerAlias: "zcode",
spawnArgs: ["app-server"],
protocol: "stdio",
},
{
id: "opencode",
name: "OpenCode",
binary: "opencode",
versionCommand: "opencode --version",
providerAlias: "opencode",
spawnArgs: [],
protocol: "stdio",
},
{
id: "cline",
name: "Cline",
binary: "cline",
versionCommand: "cline --version",
providerAlias: "cline",
spawnArgs: [],
protocol: "stdio",
},
{
id: "qwen",
name: "Qwen Code",
binary: "qwen",
versionCommand: "qwen --version",
providerAlias: "qwen-code",
spawnArgs: ["--acp"],
protocol: "stdio",
},
{
id: "forge",
name: "ForgeCode",
binary: "forge",
versionCommand: "forge --version",
providerAlias: "forge",
spawnArgs: [],
protocol: "stdio",
},
{
id: "amazon-q",
name: "Amazon Q Developer",
binary: "q",
versionCommand: "q --version",
providerAlias: "amazon-q",
spawnArgs: [],
protocol: "stdio",
},
{
id: "interpreter",
name: "Open Interpreter",
binary: "interpreter",
versionCommand: "interpreter --version",
providerAlias: "interpreter",
spawnArgs: [],
protocol: "stdio",
},
{
id: "cursor-cli",
name: "Cursor CLI",
binary: "cursor",
versionCommand: "cursor --version",
providerAlias: "cursor",
spawnArgs: [],
protocol: "stdio",
},
{
id: "warp",
name: "Warp AI",
binary: "warp",
versionCommand: "warp --version",
providerAlias: "warp",
spawnArgs: [],
protocol: "stdio",
},
];
// ---------------------------------------------------------------------------
// Detection cache (60 seconds)
// ---------------------------------------------------------------------------
let _cachedAgents: CliAgentInfo[] | null = null;
let _cacheTimestamp = 0;
const CACHE_TTL_MS = 60_000;
/** Custom agents loaded from settings */
let _customAgentDefs: CustomAgentDef[] = [];
const DISALLOWED_VERSION_COMMAND_CHARS = /[;&|<>`$\r\n]/;
// A version probe only ever needs a version flag. For untrusted (client-registered)
// custom agents the binary-match check alone is not enough: the caller controls both
// `binary` and `versionCommand`, so a matching interpreter with an eval-style argument
// (`node -e …`, `python -c …`, `ruby -e …`) reaches execFileSync as arbitrary code
// execution without any shell metacharacter. Restricting the args to a recognized
// version flag closes that path — see GHSA-jphr-2gw7-xrwp / GHSA-hf57-cqmx-p4gr.
const SAFE_VERSION_PROBE_ARG = /^(-v|-V|--version|-version|version|--ver)$/;
/**
* Set custom agent definitions from settings.
*/
export function setCustomAgents(agents: CustomAgentDef[]): void {
_customAgentDefs = agents || [];
_cachedAgents = null; // invalidate cache
}
/**
* Get current custom agent definitions.
*/
export function getCustomAgentDefs(): CustomAgentDef[] {
return _customAgentDefs;
}
function tokenizeVersionCommand(command: string): string[] | null {
if (!command || DISALLOWED_VERSION_COMMAND_CHARS.test(command)) {
return null;
}
const tokens: string[] = [];
let current = "";
let quote: '"' | "'" | null = null;
for (let index = 0; index < command.length; index += 1) {
const char = command[index];
if (quote) {
if (char === quote) {
quote = null;
} else {
current += char;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current);
current = "";
}
continue;
}
if (char === "\\") {
const next = command[index + 1];
if (next) {
current += next;
index += 1;
continue;
}
}
current += char;
}
if (quote) {
return null;
}
if (current) {
tokens.push(current);
}
return tokens.length > 0 ? tokens : null;
}
function normalizeCommandToken(command: string): string {
return path.normalize(command).replace(/\\/g, "/").toLowerCase();
}
export function resolveVersionProbe(
binary: string,
versionCommand: string,
requireBinaryMatch = false
): { command: string; args: string[] } | null {
const tokens = tokenizeVersionCommand(versionCommand);
if (!tokens) {
return null;
}
const [command, ...args] = tokens;
if (!command) {
return null;
}
if (requireBinaryMatch) {
const normalizedCommand = normalizeCommandToken(command);
const allowed = new Set([
normalizeCommandToken(binary),
normalizeCommandToken(path.basename(binary)),
]);
if (!allowed.has(normalizedCommand)) {
return null;
}
// Untrusted probe: allow only a bare binary or a single recognized version
// flag, so a matching interpreter cannot smuggle an eval/exec argument.
if (args.length > 1 || (args.length === 1 && !SAFE_VERSION_PROBE_ARG.test(args[0]))) {
return null;
}
}
return { command, args };
}
export function shouldUseShellForVersionProbe(
command: string,
platform = process.platform
): boolean {
if (platform !== "win32") return false;
const normalized = command.trim().toLowerCase();
if (!normalized) return false;
return (
normalized.endsWith(".cmd") || normalized.endsWith(".bat") || path.extname(normalized) === ""
);
}
/**
* Detect a single agent by running its version command.
*/
function detectAgent(
def: Omit<CliAgentInfo, "version" | "installed">,
isCustom = false
): CliAgentInfo {
let version: string | null = null;
let installed = false;
try {
const probe = resolveVersionProbe(def.binary, def.versionCommand, isCustom);
if (!probe) {
return { ...def, version, installed, isCustom };
}
const output = execFileSync(probe.command, probe.args, {
timeout: 5000,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
...(shouldUseShellForVersionProbe(probe.command) ? { shell: true } : {}),
}).trim();
// Extract version number from output
const versionMatch = output.match(/(\d+\.\d+\.\d+(?:-\w+)?)/);
version = versionMatch ? versionMatch[1] : output.split("\n")[0];
installed = true;
} catch {
// Not installed or not runnable
}
return { ...def, version, installed, isCustom };
}
/**
* Detect installed CLI agents on the system.
* Results are cached for 60 seconds.
*/
export function detectInstalledAgents(): CliAgentInfo[] {
const now = Date.now();
if (_cachedAgents && now - _cacheTimestamp < CACHE_TTL_MS) {
return _cachedAgents;
}
// Merge built-in + custom definitions
const allDefs = [
...AGENT_DEFINITIONS.map((d) => ({ ...d, _custom: false })),
..._customAgentDefs.map((d) => ({ ...d, _custom: true })),
];
_cachedAgents = allDefs.map((def) => {
const { _custom, ...rest } = def;
return detectAgent(rest, _custom);
});
_cacheTimestamp = now;
return _cachedAgents;
}
/**
* Force refresh detection cache.
*/
export function refreshAgentCache(): CliAgentInfo[] {
_cachedAgents = null;
return detectInstalledAgents();
}
/**
* Get a specific agent by ID.
*/
export function getAgentById(id: string): CliAgentInfo | undefined {
const agents = detectInstalledAgents();
return agents.find((a) => a.id === id);
}
/**
* Check registration without probing every executable on PATH.
*
* Process lifecycle callers need an allowlist decision, not a fresh health
* scan. Keeping this lookup pure avoids making `spawn()` wait on one timeout
* per uninstalled agent while preserving detectInstalledAgents() for UI/status
* consumers.
*/
export function hasRegisteredAgent(id: string): boolean {
const normalized = String(id || "")
.trim()
.toLowerCase();
return (
AGENT_DEFINITIONS.some((agent) => agent.id === normalized) ||
_customAgentDefs.some((agent) => agent.id === normalized)
);
}
/**
* Get agents that are installed and available for ACP.
*/
export function getAvailableAgents(): CliAgentInfo[] {
return detectInstalledAgents().filter((a) => a.installed);
}