Files
openclaw/src/agents/bash-tools.exec-script-ambiguity.ts
Peter Steinberger 73bead1239 refactor(agents): split exec tool pipeline (#113807)
* refactor(agents): split exec tool pipeline

* chore(agents): keep exec prep type private
2026-07-25 13:58:01 -07:00

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,
};
}