openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
195 lines (194 loc) • 9.67 kB
JavaScript
import { o as normalizeLowercaseStringOrEmpty } from "./string-coerce-1VuiLaaR.mjs";
import "./src-BirUqOZp.mjs";
import { t as expectDefined } from "./expect-lbe3Hgrh.mjs";
import { d as normalizeStringEntries, y as uniqueStrings } from "./string-normalization-D6DKCpwE.mjs";
import { n as resolvePathViaExistingAncestorSync } from "./boundary-path-CAv_plX7.mjs";
import { r as isPathInside } from "./path-guards-CH7noY0Z.mjs";
import { r as splitShellArgs } from "./shell-argv-Dy8gqirH.mjs";
import { t as buildCommandPayloadArgvCandidates } from "./risks-DrjRPyXj.mjs";
import { n as explainShellCommand, t as CommandExplanationWorkLimitError } from "./extract-D2O4L5cy.mjs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import os from "node:os";
//#region src/infra/exec-control-command-guard.ts
const SQLITE_OPTIONS_WITH_VALUES = /* @__PURE__ */ new Map([
["cmd", 1],
["escape", 1],
["heap", 1],
["init", 1],
["lookaside", 2],
["maxsize", 1],
["mmap", 1],
["newline", 1],
["nonce", 1],
["nullvalue", 1],
["pagecache", 2],
["separator", 1],
["vfs", 1]
]);
const SQLITE_OPEN_OPTIONS_WITH_VALUES = /* @__PURE__ */ new Map([
["hexkey", 1],
["key", 1],
["maxsize", 1],
["textkey", 1]
]);
function isExecApprovalShellCommand(raw) {
return /^\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(allow-once|allow-always|always|deny)\b/i.test(raw.trimStart());
}
function normalizeCommandBaseName(token) {
if (!token) return "";
return normalizeLowercaseStringOrEmpty(token.split(/[\\/]/u).at(-1)).replace(/\.(?:cmd|exe)$/u, "");
}
function stripOpenClawPackageRunner(argv) {
const commandName = normalizeCommandBaseName(argv[0]);
if (commandName === "openclaw") return argv;
if ((commandName === "pnpm" || commandName === "npm" || commandName === "yarn") && normalizeCommandBaseName(argv[1]) === "openclaw") return argv.slice(1);
if ((commandName === "pnpm" || commandName === "npm" || commandName === "yarn") && (argv[1] === "exec" || argv[1] === "dlx" || argv[1] === "run") && normalizeCommandBaseName(argv[2]) === "openclaw") return argv.slice(2);
if (commandName === "npx" || commandName === "bunx") {
let idx = 1;
while (idx < argv.length) {
const token = expectDefined(argv[idx], "argv entry at idx");
if (token === "--") {
idx += 1;
break;
}
if (!token.startsWith("-") || token === "-") break;
idx += 1;
if ((token === "-p" || token === "--package") && idx < argv.length) idx += 1;
}
if (normalizeCommandBaseName(argv[idx]) === "openclaw") return argv.slice(idx);
}
return argv;
}
function parseOpenClawChannelsLoginShellCommand(raw) {
const argv = splitShellArgs(raw);
if (!argv) return false;
const openclawArgv = stripOpenClawPackageRunner(argv);
return normalizeCommandBaseName(openclawArgv[0]) === "openclaw" && (openclawArgv[1] === "channels" || openclawArgv[1] === "channel") && openclawArgv[2] === "login";
}
function parseSqliteOpenCommandDatabaseToken(command) {
const argv = splitShellArgs(command);
if (!argv || !/^\.op(?:e(?:n)?)?$/u.test(argv[0] ?? "")) return null;
for (let index = 1; index < argv.length; index += 1) {
const token = expectDefined(argv[index], "sqlite3 .open argv entry");
if (token === "--") return argv[index + 1] ?? null;
if (token === "-" || !token.startsWith("-")) return token;
const optionName = token.replace(/^-+/u, "").split("=", 1)[0]?.toLowerCase() ?? "";
if (!token.includes("=")) index += SQLITE_OPEN_OPTIONS_WITH_VALUES.get(optionName) ?? 0;
}
return null;
}
function parseSqliteDatabaseTokens(argv) {
if (normalizeCommandBaseName(argv[0]) !== "sqlite3") return [];
const databaseTokens = [];
const commandTokens = [];
for (let index = 1; index < argv.length; index += 1) {
const token = expectDefined(argv[index], "sqlite3 argv entry");
if (token === "--") {
const databaseToken = argv[index + 1];
if (databaseToken) databaseTokens.push(databaseToken);
commandTokens.push(...argv.slice(index + 2));
break;
}
if (token === "-" || !token.startsWith("-")) {
databaseTokens.push(token);
commandTokens.push(...argv.slice(index + 1));
break;
}
const optionName = token.replace(/^-+/u, "").split("=", 1)[0]?.toLowerCase() ?? "";
if (optionName === "cmd") {
const commandToken = token.includes("=") ? token.slice(token.indexOf("=") + 1) : argv[index + 1];
if (commandToken) commandTokens.push(commandToken);
}
if (!token.includes("=")) index += SQLITE_OPTIONS_WITH_VALUES.get(optionName) ?? 0;
}
for (const commandToken of commandTokens) {
const databaseToken = parseSqliteOpenCommandDatabaseToken(commandToken);
if (databaseToken) databaseTokens.push(databaseToken);
}
return databaseTokens;
}
const SQLITE_URI_RECOGNIZED_VARIABLE_BODY = /^(?:\/\/(?:localhost)?)?\$(?:\{?OPENCLAW_STATE_DIR\}?|\{?HOME\}?)(?=$|[\\/])/iu;
const SQLITE_URI_EMPTY_AUTHORITY = /^\/\/(?:localhost)?(?=\$)/iu;
function expandSqliteDatabaseToken(token, stateDir) {
let expanded = token.trim();
if (!expanded || expanded === ":memory:") return null;
const fileUriBody = expanded.toLowerCase().startsWith("file:") ? expanded.slice(5) : null;
if (fileUriBody !== null && SQLITE_URI_RECOGNIZED_VARIABLE_BODY.test(fileUriBody)) {
const filename = fileUriBody.replace(SQLITE_URI_EMPTY_AUTHORITY, "").split(/[?#]/u, 1)[0] ?? "";
if (!filename) return null;
try {
expanded = decodeURIComponent(filename);
} catch {
expanded = filename;
}
} else if (fileUriBody !== null) try {
expanded = fileURLToPath(expanded);
} catch {
const filename = fileUriBody.split(/[?#]/u, 1)[0] ?? "";
if (!filename) return null;
try {
expanded = decodeURIComponent(filename);
} catch {
expanded = filename;
}
}
const stateVariable = expanded.match(/^\$(?:OPENCLAW_STATE_DIR|\{OPENCLAW_STATE_DIR\})(?=$|[\\/])/u);
if (stateVariable) expanded = `${stateDir}${expanded.slice(stateVariable[0].length)}`;
const homeVariable = expanded.match(/^\$(?:HOME|\{HOME\})(?=$|[\\/])/u);
if (homeVariable) expanded = `${os.homedir()}${expanded.slice(homeVariable[0].length)}`;
if (expanded === "~" || expanded.startsWith("~/") || expanded.startsWith("~\\")) expanded = path.join(os.homedir(), expanded.slice(2));
return expanded;
}
function targetsLiveStateSqliteDatabase(argv, context) {
const stateDir = context.stateDir?.trim();
if (!stateDir) return false;
const canonicalStateDir = resolvePathViaExistingAncestorSync(stateDir);
return parseSqliteDatabaseTokens(argv).some((databaseToken) => {
const expandedTarget = expandSqliteDatabaseToken(databaseToken, stateDir);
if (!expandedTarget) return false;
const targetPath = path.isAbsolute(expandedTarget) ? expandedTarget : path.resolve(context.workdir ?? process.cwd(), expandedTarget);
const canonicalTarget = resolvePathViaExistingAncestorSync(targetPath);
return canonicalTarget === canonicalStateDir || isPathInside(canonicalStateDir, canonicalTarget);
});
}
async function detectUnsafeExecControlShellCommand(command, context = {}) {
const rawCommand = command.trim();
let explanation = null;
try {
explanation = await explainShellCommand(rawCommand);
} catch (error) {
if (error instanceof CommandExplanationWorkLimitError) return "incomplete-analysis";
}
const argvCandidates = (() => {
if (explanation?.ok) return [...explanation.topLevelCommands, ...explanation.nestedCommands].flatMap((step) => buildCommandPayloadArgvCandidates(step.argv));
return normalizeStringEntries(rawCommand.split(/\r?\n/)).flatMap((line) => {
const argv = splitShellArgs(line);
return argv ? buildCommandPayloadArgvCandidates(argv) : [[line]];
});
})();
const controlCandidates = uniqueStrings(argvCandidates.map((argv) => argv.join(" ")));
for (const candidate of controlCandidates) {
if (isExecApprovalShellCommand(candidate)) return "approve";
if (parseOpenClawChannelsLoginShellCommand(candidate)) return "channel-login";
}
for (const candidateArgv of argvCandidates) if (targetsLiveStateSqliteDatabase(candidateArgv, context)) return "live-state-sqlite";
return null;
}
function rejectIncompleteCommandAnalysis(unsafeKind) {
if (unsafeKind === "incomplete-analysis") throw new Error("exec cannot run a shell command that exceeds the command explanation work limit. Simplify the command so its complete syntax can be inspected before execution.");
}
async function rejectUnsafeExecControlShellCommand(command) {
const unsafeKind = await detectUnsafeExecControlShellCommand(command);
rejectIncompleteCommandAnalysis(unsafeKind);
if (unsafeKind === "approve") throw new Error(["exec cannot run /approve commands.", "Show the /approve command to the user as chat text, or route it through the approval command handler instead of shell execution."].join(" "));
if (unsafeKind === "channel-login") throw new Error(["exec cannot run interactive OpenClaw channel login commands.", "Run `openclaw channels login` in a terminal on the gateway host, or use the channel-specific login agent tool when available (for WhatsApp: `whatsapp_login`)."].join(" "));
}
async function rejectUnsafeExecLiveStateSqliteShellCommand(command, context) {
const unsafeKind = await detectUnsafeExecControlShellCommand(command, context);
rejectIncompleteCommandAnalysis(unsafeKind);
if (unsafeKind !== "live-state-sqlite") return;
throw new Error(["external sqlite3 cannot open databases under the active OpenClaw state directory.", "Use OpenClaw commands for live state, or inspect a private backup copy outside `OPENCLAW_STATE_DIR`."].join(" "));
}
//#endregion
export { rejectUnsafeExecControlShellCommand as n, rejectUnsafeExecLiveStateSqliteShellCommand as r, detectUnsafeExecControlShellCommand as t };