UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

355 lines (354 loc) • 12.5 kB
//#region src/agents/sandbox/remote-shell-command.ts /** Shared remote-shell quoting, workdir validation, and directory contracts. */ function shellEscape(value) { return `'${value.replaceAll("'", `'"'"'`)}'`; } /** Build a remote shell command from literal argv entries. */ function buildRemoteCommand(argv) { return argv.map((entry) => shellEscape(entry)).join(" "); } function assertValidExecRemoteCommand(command) { const frames = [{ kind: "root", quote: "plain", escaping: false, parenDepth: 0 }]; const pendingHeredocs = []; for (let index = 0; index < command.length; index += 1) { const frame = frames.at(-1); if (!frame) throw new Error("Malformed SSH/OpenShell exec command: parser state underflow."); const char = command.charAt(index); if (frame.escaping) { frame.escaping = false; continue; } if (frame.quote === "single") { if (char === "'") frame.quote = "plain"; continue; } if (char === "\\") { frame.escaping = true; continue; } if (frame.quote === "double") { if (char === "\"") { frame.quote = "plain"; continue; } if (char === "`") { frames.push(createExecCommandFrame("backtick")); continue; } if (char === "$" && command[index + 1] === "(" && command[index + 2] === "(") { frames.push(createExecCommandFrame("arithmetic", 2)); index += 2; continue; } if (char === "$" && command[index + 1] === "(") { frames.push(createExecCommandFrame("command-substitution", 1)); index += 1; } continue; } if (frame.kind === "arithmetic") { if (char === "(") { frame.parenDepth += 1; continue; } if (char === ")") { frame.parenDepth -= 1; if (frame.parenDepth === 0) frames.pop(); } continue; } if (char === "\n") { const frameHeredocs = pendingHeredocs.filter((pending) => pending.frameDepth === frames.length); if (frameHeredocs.length > 0) { index = skipHeredocBodies(command, index + 1, frameHeredocs) - 1; for (const pending of frameHeredocs) pendingHeredocs.splice(pendingHeredocs.indexOf(pending), 1); continue; } } if (frame.kind === "backtick" && char === "`") { frames.pop(); continue; } if (char === "'") { frame.quote = "single"; continue; } if (char === "\"") { frame.quote = "double"; continue; } if (char === "`") { frames.push(createExecCommandFrame("backtick")); continue; } if (char === "$" && command[index + 1] === "(" && command[index + 2] === "(") { frames.push(createExecCommandFrame("arithmetic", 2)); index += 2; continue; } if (char === "$" && command[index + 1] === "(") { frames.push(createExecCommandFrame("command-substitution", 1)); index += 1; continue; } if (char === "#" && isShellCommentStart(command, index)) { index = skipShellComment(command, index) - 1; continue; } if (char === "<") { const heredoc = readHeredoc(command, index); if (heredoc) { pendingHeredocs.push({ ...heredoc.pending, frameDepth: frames.length }); index = heredoc.endIndex - 1; continue; } const placeholder = readPlaceholderToken(command, index); if (placeholder) throw new Error(`Malformed SSH/OpenShell exec command: unresolved placeholder token ${placeholder}.`); } if (frame.kind === "command-substitution") { if (char === "(") { frame.parenDepth += 1; continue; } if (char === ")") { frame.parenDepth -= 1; if (frame.parenDepth === 0) frames.pop(); } } } if (frames.at(-1)?.escaping) throw new Error("Malformed SSH/OpenShell exec command: trailing backslash escape."); if (pendingHeredocs.length > 0) { const pending = pendingHeredocs.at(0); if (!pending) throw new Error("Malformed SSH/OpenShell exec command: parser state underflow."); throw new Error(`Malformed SSH/OpenShell exec command: unterminated here-doc ${pending.delimiter}.`); } for (const frame of frames.toReversed()) { if (frame.quote === "single") throw new Error("Malformed SSH/OpenShell exec command: unclosed single quote."); if (frame.quote === "double") throw new Error("Malformed SSH/OpenShell exec command: unclosed double quote."); if (frame.kind === "backtick") throw new Error("Malformed SSH/OpenShell exec command: unterminated backtick command substitution."); if (frame.kind === "command-substitution") throw new Error("Malformed SSH/OpenShell exec command: unterminated command substitution."); if (frame.kind === "arithmetic") throw new Error("Malformed SSH/OpenShell exec command: unterminated arithmetic expansion."); } } /** Build the wrapped remote `/bin/sh -c` command for sandbox exec. */ function buildExecRemoteCommand(params) { if (Object.keys(params.env).length > 0) throw new Error("SSH sandbox environment requires secure script staging; use prepareSshSandboxExec."); return buildRemoteCommand([ "/bin/sh", "-c", params.workdir ? `cd ${shellEscape(params.workdir)} && ${params.command}` : params.command ]); } /** Validate and build a remote exec command for untrusted model input. */ function buildValidatedExecRemoteCommand(params) { assertValidExecRemoteCommand(params.command); return buildExecRemoteCommand(params); } function buildRemoteDirectoryScript(create) { return [ "set -e", "target=\"$1\"", create ? "root=\"${2:-$1}\"" : "root=\"$2\"", "case \"$target\" in /*) ;; *) echo \"remote directory must be absolute: $target\" >&2; exit 1 ;; esac", "case \"$root\" in /*) ;; *) echo \"remote root must be absolute: $root\" >&2; exit 1 ;; esac", "target=\"${target%/}\"", "root=\"${root%/}\"", "[ -n \"$target\" ] || target=\"/\"", "[ -n \"$root\" ] || root=\"/\"", ...create ? ["case \"$target/\" in \"$root\"/*|\"$root/\") ;; *) echo \"remote directory must stay under root: $target\" >&2; exit 1 ;; esac"] : [ "if [ \"$root\" != \"/\" ]; then", " case \"$target/\" in \"$root\"/*|\"$root/\") ;; *) echo \"remote directory must stay under root: $target\" >&2; exit 1 ;; esac", "fi" ], "for path_to_check in \"$target\" \"$root\"; do", " relative=\"${path_to_check#/}\"", " while [ -n \"$relative\" ]; do", " part=\"${relative%%/*}\"", " if [ \"$part\" = \"$relative\" ]; then relative=\"\"; else relative=\"${relative#*/}\"; fi", " [ -n \"$part\" ] || continue", " case \"$part\" in \".\"|\"..\") echo \"unsafe remote directory component: $part\" >&2; exit 1 ;; esac", " done", "done", "if [ -L \"$root\" ]; then echo \"unsafe remote root symlink: $root\" >&2; exit 1; fi", create ? "mkdir -p -- \"$root\"" : "if [ ! -d \"$root\" ]; then echo \"remote root not found: $root\" >&2; exit 1; fi", "canonical_root=\"$(cd \"$root\" && pwd -P)\"", "relative=\"${target#\"$root\"}\"", "relative=\"${relative#/}\"", "current=\"$canonical_root\"", "while [ -n \"$relative\" ]; do", " part=\"${relative%%/*}\"", " if [ \"$part\" = \"$relative\" ]; then relative=\"\"; else relative=\"${relative#*/}\"; fi", " [ -n \"$part\" ] || continue", " if [ \"$current\" = \"/\" ]; then next=\"/$part\"; else next=\"$current/$part\"; fi", " if [ -L \"$next\" ]; then echo \"unsafe remote directory symlink: $next\" >&2; exit 1; fi", ...create ? [ " if [ -e \"$next\" ]; then", " if [ ! -d \"$next\" ]; then echo \"unsafe remote directory component: $next\" >&2; exit 1; fi", " else", " mkdir -- \"$next\"", " fi" ] : [" if [ ! -d \"$next\" ]; then echo \"remote directory not found: $next\" >&2; exit 1; fi"], " current=\"$next\"", "done", ...create ? [] : ["printf \"%s\\n\" \"$current\""] ].join("\n"); } const VALIDATE_REMOTE_WORKDIR_SCRIPT = buildRemoteDirectoryScript(false); function buildRemoteWorkdirValidationCommand(params) { return buildRemoteCommand([ "/bin/sh", "-c", VALIDATE_REMOTE_WORKDIR_SCRIPT, "openclaw-validate-workdir", params.workdir, params.root ]); } function createExecCommandFrame(kind, parenDepth = 0) { return { kind, quote: "plain", escaping: false, parenDepth }; } function readPlaceholderToken(command, index) { const match = /^<[A-Za-z][A-Za-z0-9_-]*>/.exec(command.slice(index)); if (!match) return null; if (command[index - 1] === "=") return match[0]; if (isLikelyGeneratedWorkflowPlaceholder(command, index)) return match[0]; const next = command[index + match[0].length]; if (next === void 0 || /[\r\n;&|)]/.test(next)) return match[0]; if (next === " " || next === " ") return hasRedirectionTargetAfter(command, index + match[0].length) ? null : match[0]; return null; } function hasRedirectionTargetAfter(command, index) { let cursor = index; while (command.charAt(cursor) === " " || command.charAt(cursor) === " ") cursor += 1; const next = command.charAt(cursor); return next !== "" && !/[;&|()<>\r\n]/.test(next); } function isLikelyGeneratedWorkflowPlaceholder(command, index) { const prefix = command.slice(0, index); const segmentStart = Math.max(prefix.lastIndexOf("\n"), prefix.lastIndexOf(";"), prefix.lastIndexOf("&"), prefix.lastIndexOf("|"), prefix.lastIndexOf("("), prefix.lastIndexOf("`")) + 1; const currentCommand = prefix.slice(segmentStart).trim(); return /^workflow(?:\s+[A-Za-z0-9._/-]+)*$/.test(currentCommand); } function readHeredoc(command, index) { if (command[index + 1] !== "<" || command[index + 2] === "<") return null; let cursor = index + 2; const stripLeadingTabs = command[cursor] === "-"; if (stripLeadingTabs) cursor += 1; while (command[cursor] === " " || command[cursor] === " ") cursor += 1; const delimiter = readHeredocDelimiter(command, cursor); if (!delimiter) throw new Error("Malformed SSH/OpenShell exec command: missing here-doc delimiter."); return { pending: { delimiter: delimiter.value, stripLeadingTabs }, endIndex: delimiter.endIndex }; } function readHeredocDelimiter(command, index) { let cursor = index; let delimiter = ""; let quote = "plain"; let escaping = false; while (cursor < command.length) { const char = command[cursor]; if (escaping) { delimiter += char; escaping = false; cursor += 1; continue; } if (quote === "single") { if (char === "'") quote = "plain"; else delimiter += char; cursor += 1; continue; } if (quote === "double") { if (char === "\"") quote = "plain"; else if (char === "\\") escaping = true; else delimiter += char; cursor += 1; continue; } if (char === "\\") { escaping = true; cursor += 1; continue; } if (char === "'") { quote = "single"; cursor += 1; continue; } if (char === "\"") { quote = "double"; cursor += 1; continue; } if (isHeredocDelimiterTerminator(char)) break; delimiter += char; cursor += 1; } if (quote !== "plain" || escaping) throw new Error("Malformed SSH/OpenShell exec command: unterminated here-doc delimiter."); return delimiter ? { value: delimiter, endIndex: cursor } : null; } function isHeredocDelimiterTerminator(char) { return char === void 0 || /\s/.test(char) || [ ";", "&", "|", "(", ")", "<", ">" ].includes(char); } function skipHeredocBodies(command, index, pendingHeredocs) { let cursor = index; for (const pending of pendingHeredocs) { let found = false; while (cursor <= command.length) { const lineEnd = command.indexOf("\n", cursor); const endIndex = lineEnd === -1 ? command.length : lineEnd; const rawLine = command.slice(cursor, endIndex); const normalizedLine = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; const line = pending.stripLeadingTabs ? normalizedLine.replace(/^\t+/, "") : normalizedLine; cursor = lineEnd === -1 ? command.length : lineEnd + 1; if (line === pending.delimiter) { found = true; break; } if (lineEnd === -1) break; } if (!found) throw new Error(`Malformed SSH/OpenShell exec command: unterminated here-doc ${pending.delimiter}.`); } return cursor; } function isShellCommentStart(command, index) { const previous = command[index - 1]; return previous === void 0 || /[\s;&|()]/.test(previous); } function skipShellComment(command, index) { const newlineIndex = command.indexOf("\n", index); return newlineIndex === -1 ? command.length : newlineIndex; } const ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT = buildRemoteDirectoryScript(true); //#endregion export { buildValidatedExecRemoteCommand as a, buildRemoteWorkdirValidationCommand as i, buildExecRemoteCommand as n, shellEscape as o, buildRemoteCommand as r, ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT as t };