mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-03 13:31:33 +00:00
* refactor(agents): split exec tool pipeline * chore(agents): keep exec prep type private
525 lines
14 KiB
TypeScript
525 lines
14 KiB
TypeScript
/** Detects ambiguous interpreter invocations that cannot be safely preflighted. */
|
|
import {
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeOptionalLowercaseString,
|
|
} from "@openclaw/normalization-core/string-coerce";
|
|
import { splitShellArgs } from "../utils/shell-argv.js";
|
|
import {
|
|
extractInterpreterScriptPathsFromSegment,
|
|
stripPreflightEnvPrefix,
|
|
} from "./bash-tools.exec-script-target.js";
|
|
|
|
function extractUnquotedShellText(raw: string): string | null {
|
|
let out = "";
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
let escaped = false;
|
|
|
|
for (let i = 0; i < raw.length; i += 1) {
|
|
const ch = raw[i];
|
|
if (escaped) {
|
|
if (!inSingle && !inDouble) {
|
|
// Preserve escapes outside quotes so downstream heuristics can distinguish
|
|
// escaped literals (e.g. `\|`) from executable shell operators.
|
|
out += `\\${ch}`;
|
|
}
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (!inSingle && ch === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (inSingle) {
|
|
if (ch === "'") {
|
|
inSingle = false;
|
|
}
|
|
continue;
|
|
}
|
|
if (inDouble) {
|
|
const next = raw[i + 1];
|
|
if (ch === "\\" && next && /[\\'"$`\n\r]/.test(next)) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = false;
|
|
}
|
|
continue;
|
|
}
|
|
if (ch === "'") {
|
|
inSingle = true;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = true;
|
|
continue;
|
|
}
|
|
out += ch;
|
|
}
|
|
|
|
if (escaped || inSingle || inDouble) {
|
|
return null;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function splitShellSegmentsOutsideQuotes(
|
|
rawText: string,
|
|
params: { splitPipes: boolean },
|
|
): string[] {
|
|
const segments: string[] = [];
|
|
let buf = "";
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
let escaped = false;
|
|
|
|
const pushSegment = () => {
|
|
if (buf.trim().length > 0) {
|
|
segments.push(buf);
|
|
}
|
|
buf = "";
|
|
};
|
|
|
|
for (let i = 0; i < rawText.length; i += 1) {
|
|
const ch = rawText[i];
|
|
const next = rawText[i + 1];
|
|
|
|
if (escaped) {
|
|
buf += ch;
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (!inSingle && ch === "\\") {
|
|
buf += ch;
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
|
|
if (inSingle) {
|
|
buf += ch;
|
|
if (ch === "'") {
|
|
inSingle = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (inDouble) {
|
|
buf += ch;
|
|
if (ch === '"') {
|
|
inDouble = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (ch === "'") {
|
|
inSingle = true;
|
|
buf += ch;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '"') {
|
|
inDouble = true;
|
|
buf += ch;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "\n" || ch === "\r") {
|
|
pushSegment();
|
|
continue;
|
|
}
|
|
if (ch === ";") {
|
|
pushSegment();
|
|
continue;
|
|
}
|
|
if (ch === "&" && next === "&") {
|
|
pushSegment();
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (ch === "|" && next === "|") {
|
|
pushSegment();
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (params.splitPipes && ch === "|") {
|
|
pushSegment();
|
|
continue;
|
|
}
|
|
|
|
buf += ch;
|
|
}
|
|
pushSegment();
|
|
return segments;
|
|
}
|
|
|
|
function isInterpreterExecutable(executable: string | undefined): boolean {
|
|
if (!executable) {
|
|
return false;
|
|
}
|
|
return /^python(?:3(?:\.\d+)?)?$/i.test(executable) || executable === "node";
|
|
}
|
|
|
|
function hasUnescapedSequence(raw: string, sequence: string): boolean {
|
|
if (sequence.length === 0) {
|
|
return false;
|
|
}
|
|
let escaped = false;
|
|
for (let i = 0; i < raw.length; i += 1) {
|
|
const ch = raw[i];
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (ch === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (raw.startsWith(sequence, i)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function hasUnquotedScriptHint(raw: string): boolean {
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
let escaped = false;
|
|
let token = "";
|
|
|
|
const flushToken = (): boolean => {
|
|
const normalizedToken = normalizeLowercaseStringOrEmpty(token);
|
|
if (normalizedToken.endsWith(".py") || normalizedToken.endsWith(".js")) {
|
|
return true;
|
|
}
|
|
token = "";
|
|
return false;
|
|
};
|
|
|
|
for (const ch of raw) {
|
|
if (escaped) {
|
|
if (!inSingle && !inDouble) {
|
|
token += ch;
|
|
}
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (!inSingle && ch === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (inSingle) {
|
|
if (ch === "'") {
|
|
inSingle = false;
|
|
}
|
|
continue;
|
|
}
|
|
if (inDouble) {
|
|
if (ch === '"') {
|
|
inDouble = false;
|
|
}
|
|
continue;
|
|
}
|
|
if (ch === "'") {
|
|
if (flushToken()) {
|
|
return true;
|
|
}
|
|
inSingle = true;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
if (flushToken()) {
|
|
return true;
|
|
}
|
|
inDouble = true;
|
|
continue;
|
|
}
|
|
if (/\s/u.test(ch) || "|&;()<>".includes(ch)) {
|
|
if (flushToken()) {
|
|
return true;
|
|
}
|
|
continue;
|
|
}
|
|
token += ch;
|
|
}
|
|
return flushToken();
|
|
}
|
|
|
|
function resolveLeadingShellSegmentExecutable(rawSegment: string): string | undefined {
|
|
const segment = (extractUnquotedShellText(rawSegment) ?? rawSegment).trim();
|
|
const argv = splitShellArgs(segment);
|
|
if (!argv || argv.length === 0) {
|
|
return undefined;
|
|
}
|
|
const withoutLeadingKeyword = /^(?:if|then|do|elif|else|while|until|time)$/i.test(argv[0] ?? "")
|
|
? argv.slice(1)
|
|
: argv;
|
|
if (withoutLeadingKeyword.length === 0) {
|
|
return undefined;
|
|
}
|
|
const normalizedArgv = stripPreflightEnvPrefix(withoutLeadingKeyword);
|
|
let commandIdx = 0;
|
|
while (
|
|
commandIdx < normalizedArgv.length &&
|
|
/^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(normalizedArgv[commandIdx] ?? "")
|
|
) {
|
|
commandIdx += 1;
|
|
}
|
|
return normalizeOptionalLowercaseString(normalizedArgv[commandIdx]);
|
|
}
|
|
|
|
function analyzeInterpreterHeuristicsFromUnquoted(raw: string): {
|
|
hasPython: boolean;
|
|
hasNode: boolean;
|
|
hasComplexSyntax: boolean;
|
|
hasProcessSubstitution: boolean;
|
|
hasScriptHint: boolean;
|
|
} {
|
|
const hasPython = splitShellSegmentsOutsideQuotes(raw, { splitPipes: true }).some((segment) =>
|
|
/^python(?:3(?:\.\d+)?)?$/i.test(resolveLeadingShellSegmentExecutable(segment) ?? ""),
|
|
);
|
|
const hasNode = splitShellSegmentsOutsideQuotes(raw, { splitPipes: true }).some(
|
|
(segment) => resolveLeadingShellSegmentExecutable(segment) === "node",
|
|
);
|
|
const hasProcessSubstitution = hasUnescapedSequence(raw, "<(") || hasUnescapedSequence(raw, ">(");
|
|
const hasComplexSyntax =
|
|
hasUnescapedSequence(raw, "|") ||
|
|
hasUnescapedSequence(raw, "&&") ||
|
|
hasUnescapedSequence(raw, "||") ||
|
|
hasUnescapedSequence(raw, ";") ||
|
|
raw.includes("\n") ||
|
|
raw.includes("\r") ||
|
|
hasUnescapedSequence(raw, "$(") ||
|
|
hasUnescapedSequence(raw, "`") ||
|
|
hasProcessSubstitution;
|
|
const hasScriptHint = hasUnquotedScriptHint(raw);
|
|
|
|
return { hasPython, hasNode, hasComplexSyntax, hasProcessSubstitution, hasScriptHint };
|
|
}
|
|
|
|
function extractShellWrappedCommandPayload(
|
|
executable: string | undefined,
|
|
args: string[],
|
|
): string | null {
|
|
if (!executable) {
|
|
return null;
|
|
}
|
|
const executableBase = normalizeOptionalLowercaseString(executable.split(/[\\/]/u).at(-1)) ?? "";
|
|
const normalizedExecutable = executableBase.endsWith(".exe")
|
|
? executableBase.slice(0, -4)
|
|
: executableBase;
|
|
if (!/^(?:bash|dash|fish|ksh|sh|zsh)$/i.test(normalizedExecutable)) {
|
|
return null;
|
|
}
|
|
const shortOptionsWithSeparateValue = new Set(["-O", "-o"]);
|
|
for (let i = 0; i < args.length; i += 1) {
|
|
const arg = args.at(i);
|
|
if (arg === undefined) {
|
|
break;
|
|
}
|
|
if (arg === "--") {
|
|
return null;
|
|
}
|
|
if (arg === "-c") {
|
|
return args.at(i + 1) ?? null;
|
|
}
|
|
if (/^-[A-Za-z]+$/u.test(arg)) {
|
|
if (arg.includes("c")) {
|
|
return args.at(i + 1) ?? null;
|
|
}
|
|
if (shortOptionsWithSeparateValue.has(arg)) {
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (/^--[A-Za-z0-9][A-Za-z0-9-]*(?:=.*)?$/u.test(arg)) {
|
|
if (!arg.includes("=")) {
|
|
const next = args.at(i + 1);
|
|
if (next && next !== "--" && !next.startsWith("-")) {
|
|
i += 1;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function shouldFailClosedInterpreterPreflight(command: string): {
|
|
hasInterpreterInvocation: boolean;
|
|
hasComplexSyntax: boolean;
|
|
hasProcessSubstitution: boolean;
|
|
hasInterpreterSegmentScriptHint: boolean;
|
|
hasInterpreterPipelineScriptHint: boolean;
|
|
isDirectInterpreterCommand: boolean;
|
|
} {
|
|
const raw = command.trim();
|
|
const rawArgv = splitShellArgs(raw);
|
|
const argv = rawArgv ? stripPreflightEnvPrefix(rawArgv) : null;
|
|
let commandIdx = 0;
|
|
if (argv) {
|
|
while (
|
|
commandIdx < argv.length &&
|
|
/^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(argv[commandIdx] ?? "")
|
|
) {
|
|
commandIdx += 1;
|
|
}
|
|
}
|
|
const directExecutable = normalizeOptionalLowercaseString(argv?.[commandIdx]);
|
|
const args = argv ? argv.slice(commandIdx + 1) : [];
|
|
|
|
const isDirectPythonExecutable = Boolean(
|
|
directExecutable && /^python(?:3(?:\.\d+)?)?$/i.test(directExecutable),
|
|
);
|
|
const isDirectNodeExecutable = directExecutable === "node";
|
|
const isDirectInterpreterCommand = isDirectPythonExecutable || isDirectNodeExecutable;
|
|
|
|
const unquotedRaw = extractUnquotedShellText(raw) ?? raw;
|
|
const topLevel = analyzeInterpreterHeuristicsFromUnquoted(unquotedRaw);
|
|
|
|
const shellWrappedPayload = extractShellWrappedCommandPayload(directExecutable, args);
|
|
const nestedUnquoted = shellWrappedPayload
|
|
? (extractUnquotedShellText(shellWrappedPayload) ?? shellWrappedPayload)
|
|
: "";
|
|
const nested = shellWrappedPayload
|
|
? analyzeInterpreterHeuristicsFromUnquoted(nestedUnquoted)
|
|
: {
|
|
hasPython: false,
|
|
hasNode: false,
|
|
hasComplexSyntax: false,
|
|
hasProcessSubstitution: false,
|
|
hasScriptHint: false,
|
|
};
|
|
const hasInterpreterInvocationInSegment = (rawSegment: string): boolean =>
|
|
isInterpreterExecutable(resolveLeadingShellSegmentExecutable(rawSegment));
|
|
const isScriptExecutingInterpreterCommand = (rawCommand: string): boolean => {
|
|
const argvLocal = splitShellArgs(rawCommand.trim());
|
|
if (!argvLocal || argvLocal.length === 0) {
|
|
return false;
|
|
}
|
|
const withoutLeadingKeyword = /^(?:if|then|do|elif|else|while|until|time)$/i.test(
|
|
argvLocal[0] ?? "",
|
|
)
|
|
? argvLocal.slice(1)
|
|
: argvLocal;
|
|
if (withoutLeadingKeyword.length === 0) {
|
|
return false;
|
|
}
|
|
const normalizedArgv = stripPreflightEnvPrefix(withoutLeadingKeyword);
|
|
let commandIdxLocal = 0;
|
|
while (
|
|
commandIdxLocal < normalizedArgv.length &&
|
|
/^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(normalizedArgv[commandIdxLocal] ?? "")
|
|
) {
|
|
commandIdxLocal += 1;
|
|
}
|
|
const executable = normalizeOptionalLowercaseString(normalizedArgv[commandIdxLocal]);
|
|
if (!executable) {
|
|
return false;
|
|
}
|
|
const argsLocal = normalizedArgv.slice(commandIdxLocal + 1);
|
|
|
|
if (/^python(?:3(?:\.\d+)?)?$/i.test(executable)) {
|
|
const pythonInfoOnlyFlags = new Set(["-V", "--version", "-h", "--help"]);
|
|
if (argsLocal.some((arg) => pythonInfoOnlyFlags.has(arg))) {
|
|
return false;
|
|
}
|
|
if (
|
|
argsLocal.some(
|
|
(arg) =>
|
|
arg === "-c" ||
|
|
arg === "-m" ||
|
|
arg.startsWith("-c") ||
|
|
arg.startsWith("-m") ||
|
|
arg === "--check-hash-based-pycs",
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (executable === "node") {
|
|
const nodeInfoOnlyFlags = new Set(["-v", "--version", "-h", "--help", "-c", "--check"]);
|
|
if (argsLocal.some((arg) => nodeInfoOnlyFlags.has(arg))) {
|
|
return false;
|
|
}
|
|
if (
|
|
argsLocal.some(
|
|
(arg) =>
|
|
arg === "-e" ||
|
|
arg === "-p" ||
|
|
arg === "--eval" ||
|
|
arg === "--print" ||
|
|
arg.startsWith("--eval=") ||
|
|
arg.startsWith("--print=") ||
|
|
((arg.startsWith("-e") || arg.startsWith("-p")) && arg.length > 2),
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
const hasScriptHintInSegment = (segment: string): boolean =>
|
|
extractInterpreterScriptPathsFromSegment(segment).length > 0 || hasUnquotedScriptHint(segment);
|
|
const hasInterpreterAndScriptHintInSameSegment = (rawText: string): boolean => {
|
|
const segments = splitShellSegmentsOutsideQuotes(rawText, { splitPipes: true });
|
|
return segments.some((segment) => {
|
|
if (!isScriptExecutingInterpreterCommand(segment)) {
|
|
return false;
|
|
}
|
|
return hasScriptHintInSegment(segment);
|
|
});
|
|
};
|
|
const hasInterpreterPipelineScriptHintInSameSegment = (rawText: string): boolean => {
|
|
const commandSegments = splitShellSegmentsOutsideQuotes(rawText, { splitPipes: false });
|
|
return commandSegments.some((segment) => {
|
|
const pipelineCommands = splitShellSegmentsOutsideQuotes(segment, { splitPipes: true });
|
|
const hasScriptExecutingPipedInterpreter = pipelineCommands
|
|
.slice(1)
|
|
.some((pipelineCommand) => isScriptExecutingInterpreterCommand(pipelineCommand));
|
|
if (!hasScriptExecutingPipedInterpreter) {
|
|
return false;
|
|
}
|
|
return hasScriptHintInSegment(segment);
|
|
});
|
|
};
|
|
const hasInterpreterSegmentScriptHint =
|
|
hasInterpreterAndScriptHintInSameSegment(raw) ||
|
|
(shellWrappedPayload !== null && hasInterpreterAndScriptHintInSameSegment(shellWrappedPayload));
|
|
const hasInterpreterPipelineScriptHint =
|
|
hasInterpreterPipelineScriptHintInSameSegment(raw) ||
|
|
(shellWrappedPayload !== null &&
|
|
hasInterpreterPipelineScriptHintInSameSegment(shellWrappedPayload));
|
|
const hasShellWrappedInterpreterSegmentScriptHint =
|
|
shellWrappedPayload !== null && hasInterpreterAndScriptHintInSameSegment(shellWrappedPayload);
|
|
const hasShellWrappedInterpreterInvocation =
|
|
(nested.hasPython || nested.hasNode) &&
|
|
(hasShellWrappedInterpreterSegmentScriptHint ||
|
|
nested.hasScriptHint ||
|
|
nested.hasComplexSyntax ||
|
|
nested.hasProcessSubstitution);
|
|
const hasTopLevelInterpreterInvocation = splitShellSegmentsOutsideQuotes(raw, {
|
|
splitPipes: true,
|
|
}).some((segment) => hasInterpreterInvocationInSegment(segment));
|
|
const hasInterpreterInvocation =
|
|
isDirectInterpreterCommand ||
|
|
hasShellWrappedInterpreterInvocation ||
|
|
hasTopLevelInterpreterInvocation;
|
|
|
|
return {
|
|
hasInterpreterInvocation,
|
|
hasComplexSyntax: topLevel.hasComplexSyntax || hasShellWrappedInterpreterInvocation,
|
|
hasProcessSubstitution: topLevel.hasProcessSubstitution || nested.hasProcessSubstitution,
|
|
hasInterpreterSegmentScriptHint,
|
|
hasInterpreterPipelineScriptHint,
|
|
isDirectInterpreterCommand,
|
|
};
|
|
}
|