UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

968 lines (967 loc) 32.5 kB
import { O as normalizeExecutableToken, a as extractShellWrapperInlineCommand, c as isShellWrapperExecutable, i as extractShellWrapperCommand, t as POSIX_SHELL_WRAPPERS, u as resolveShellWrapperTransportArgv, w as SOURCE_EXECUTABLES } from "./shell-wrapper-resolution-CFL_Vekh.js"; import "./exec-wrapper-resolution-VZBsT_RG.js"; import { a as detectInlineEvalArgv, i as detectCommandCarrierArgv, n as detectCarriedShellBuiltinArgv, r as detectCarrierInlineEvalArgv, s as detectShellWrapperThroughCarrierArgv } from "./risks-DRxJ1pW5.js"; import { createRequire } from "node:module"; import fs from "node:fs"; import path from "node:path"; import * as TreeSitter from "web-tree-sitter"; //#region src/infra/command-explainer/tree-sitter-runtime.ts const require = createRequire(import.meta.url); let parserPromise = null; let parserLoader = loadParser; const MAX_COMMAND_EXPLANATION_SOURCE_CHARS = 128 * 1024; const MAX_COMMAND_EXPLANATION_PARSE_MS = 500; function resolvePackageFileForCommandExplanation(packageName, fileName) { let packageEntry; try { packageEntry = require.resolve(packageName); } catch (error) { throw new Error(`Unable to resolve ${packageName} while loading the shell command explainer parser`, { cause: error }); } let directory = path.dirname(packageEntry); const searched = []; for (let depth = 0; depth < 5; depth += 1) { const candidate = path.join(directory, fileName); searched.push(candidate); if (fs.existsSync(candidate)) return candidate; const parent = path.dirname(directory); if (parent === directory) break; directory = parent; } throw new Error(`Unable to locate ${fileName} in ${packageName} while loading the shell command explainer parser; searched ${searched.join(", ")}`); } function resolveWebTreeSitterFile(fileName) { return resolvePackageFileForCommandExplanation("web-tree-sitter", fileName); } function resolveBashWasmPath() { return resolvePackageFileForCommandExplanation("tree-sitter-bash", "tree-sitter-bash.wasm"); } async function loadParser() { await TreeSitter.Parser.init({ locateFile: resolveWebTreeSitterFile }); const language = await TreeSitter.Language.load(resolveBashWasmPath()); const parser = new TreeSitter.Parser(); parser.setLanguage(language); return parser; } function getBashParserForCommandExplanation() { parserPromise ??= parserLoader().catch((error) => { parserPromise = null; throw error; }); return parserPromise; } /** * Low-level parser access for tests and parser diagnostics. * Callers own the returned Tree and must call tree.delete(). * Prefer explainShellCommand for normal command-explainer use. */ async function parseBashForCommandExplanation(source) { if (source.length > MAX_COMMAND_EXPLANATION_SOURCE_CHARS) throw new Error("Shell command is too large to explain"); const parser = await getBashParserForCommandExplanation(); const deadlineMs = performance.now() + MAX_COMMAND_EXPLANATION_PARSE_MS; let timedOut = false; const tree = parser.parse(source, null, { progressCallback: () => { timedOut = performance.now() > deadlineMs; return timedOut; } }); if (!tree) { parser.reset(); if (timedOut) throw new Error(`tree-sitter-bash timed out after ${MAX_COMMAND_EXPLANATION_PARSE_MS}ms while parsing shell command`); throw new Error("tree-sitter-bash returned no parse tree"); } return tree; } //#endregion //#region src/infra/command-explainer/extract.ts const MAX_WRAPPER_PAYLOAD_DEPTH = 2; const PARSEABLE_SHELL_WRAPPERS = new Set(POSIX_SHELL_WRAPPERS); const ROOT_SPAN_BASE = { startIndex: 0, startPosition: { row: 0, column: 0 } }; function children(node) { return Array.from({ length: node.childCount }, (_, index) => node.child(index)).filter((child) => child !== null); } function namedChildren(node) { return Array.from({ length: node.namedChildCount }, (_, index) => node.namedChild(index)).filter((child) => child !== null); } function hasDirectChildType(node, type) { return children(node).some((child) => child.type === type); } function translatePosition(position, base) { return { row: base.row + position.row, column: position.row === 0 ? base.column + position.column : position.column }; } function translateSpan(span, base) { if (base.mapOffset) { const start = base.mapOffset(span.startIndex); const end = base.mapOffset(span.endIndex); return { startIndex: start.index, endIndex: end.index, startPosition: start.position, endPosition: end.position }; } return { startIndex: base.startIndex + span.startIndex, endIndex: base.startIndex + span.endIndex, startPosition: translatePosition(span.startPosition, base.startPosition), endPosition: translatePosition(span.endPosition, base.startPosition) }; } function spanFromNode(node, base = ROOT_SPAN_BASE) { return translateSpan({ startIndex: node.startIndex, endIndex: node.endIndex, startPosition: { row: node.startPosition.row, column: node.startPosition.column }, endPosition: { row: node.endPosition.row, column: node.endPosition.column } }, base); } function advancePosition(position, text) { let row = position.row; let column = position.column; for (let index = 0; index < text.length; index += 1) { const ch = text[index]; if (ch === "\r") { if (text[index + 1] === "\n") index += 1; row += 1; column = 0; continue; } if (ch === "\n") { row += 1; column = 0; continue; } column += 1; } return { row, column }; } function utf8ByteLengthForCodePoint(codePoint) { if (codePoint <= 127) return 1; if (codePoint <= 2047) return 2; if (codePoint <= 65535) return 3; return 4; } function utf8ByteLength(text) { let length = 0; for (let index = 0; index < text.length; index += 1) { const codePoint = text.codePointAt(index); if (codePoint === void 0) continue; length += utf8ByteLengthForCodePoint(codePoint); if (codePoint > 65535) index += 1; } return length; } function utf8ByteOffsetToStringIndex(text, byteOffset) { if (byteOffset <= 0) return 0; let currentByteOffset = 0; for (let index = 0; index < text.length; index += 1) { const codePoint = text.codePointAt(index); if (codePoint === void 0) return text.length; const codePointLength = utf8ByteLengthForCodePoint(codePoint); if (currentByteOffset + codePointLength > byteOffset) return index; currentByteOffset += codePointLength; if (currentByteOffset === byteOffset) return codePoint > 65535 ? index + 2 : index + 1; if (codePoint > 65535) index += 1; } return text.length; } function parserOffsetToStringIndex(source, rootNode) { const utf8Length = utf8ByteLength(source); if (utf8Length !== source.length && rootNode.endIndex === utf8Length) return (offset) => utf8ByteOffsetToStringIndex(source, offset); return (offset) => offset; } function spanBaseForParserSource(source, rootNode, base) { const offsetToStringIndex = parserOffsetToStringIndex(source, rootNode); return { startIndex: base.startIndex, startPosition: base.startPosition, mapOffset(offset) { const sourceIndex = offsetToStringIndex(offset); if (base.mapOffset) return base.mapOffset(sourceIndex); return { index: base.startIndex + sourceIndex, position: advancePosition(base.startPosition, source.slice(0, sourceIndex)) }; } }; } function valuePrefixLength(node) { if (node.type === "string" || node.type === "raw_string") return 1; if (node.type === "ansi_c_string") return 2; return 0; } function appendDecodedText(decoded, value, sourceEndOffset) { decoded.value += value; decoded.sourceOffsets.push(...Array.from({ length: value.length }, () => sourceEndOffset)); } function identityDecodedShellText(text, sourceOffset = 0) { return { value: text, sourceOffsets: Array.from({ length: text.length + 1 }, (_, index) => sourceOffset + index) }; } function decodedSourceOffsetsForNode(node, value) { let decoded; switch (node.type) { case "raw_string": decoded = identityDecodedShellText(node.text.slice(1, -1), 1); break; case "string": decoded = decodeDoubleQuotedTextWithOffsets(node.text); break; case "ansi_c_string": decoded = decodeAnsiCStringWithOffsets(node.text); break; default: decoded = decodeUnquotedShellTextWithOffsets(node.text); break; } if (decoded.value === value && decoded.sourceOffsets.length === value.length + 1) return decoded.sourceOffsets; const prefixLength = valuePrefixLength(node); return Array.from({ length: value.length + 1 }, (_, index) => prefixLength + index); } function argumentFromNode(index, node, value, base) { const span = spanFromNode(node, base); const decodedSourceOffsets = decodedSourceOffsetsForNode(node, value.value); return { index, text: node.text, value: value.value, span, decodedSourceOffsets }; } const DYNAMIC_WORD_NODE_TYPES = new Set([ "arithmetic_expansion", "command_substitution", "expansion", "process_substitution", "simple_expansion" ]); const COMMAND_ARGUMENT_NODE_TYPES = new Set([ "ansi_c_string", "arithmetic_expansion", "command_substitution", "concatenation", "expansion", "number", "process_substitution", "raw_string", "simple_expansion", "string", "word" ]); function hasEscapedLineContinuation(text) { return /\\(?:\r\n|[\r\n])/.test(text); } function hasExecutableLineContinuation(text) { return /^[^\s]*\\(?:\r\n|[\r\n])/.test(text); } function hasUnescapedDynamicPattern(text) { for (let index = 0; index < text.length; index += 1) { const ch = text[index]; if (ch === "\\") { index += 1; continue; } if (ch === "*" || ch === "?") return true; if (ch === "[" && text.indexOf("]", index + 1) > index + 1) return true; if (ch === "{" && text.indexOf("}", index + 1) > index + 1) return true; } return false; } function decodeUnquotedShellTextWithOffsets(text) { const decoded = { value: "", sourceOffsets: [0] }; for (let index = 0; index < text.length; index += 1) { const ch = text[index]; const next = text[index + 1]; if (ch === "\\" && next !== void 0) { if (next === "\r" && text[index + 2] === "\n") { decoded.sourceOffsets[decoded.value.length] = index + 3; index += 2; continue; } if (next === "\n" || next === "\r") { decoded.sourceOffsets[decoded.value.length] = index + 2; index += 1; continue; } appendDecodedText(decoded, next, index + 2); index += 1; continue; } appendDecodedText(decoded, ch, index + 1); } return decoded; } function decodeUnquotedShellText(text) { return decodeUnquotedShellTextWithOffsets(text).value; } function decodeDoubleQuotedTextWithOffsets(text) { const hasQuotes = text.startsWith("\"") && text.endsWith("\""); const bodyStart = hasQuotes ? 1 : 0; const body = hasQuotes ? text.slice(1, -1) : text; const decoded = { value: "", sourceOffsets: [bodyStart] }; for (let index = 0; index < body.length; index += 1) { const ch = body[index]; const next = body[index + 1]; const sourceOffset = bodyStart + index; if (ch === "\\" && next !== void 0) { if (next === "\r" && body[index + 2] === "\n") { decoded.sourceOffsets[decoded.value.length] = sourceOffset + 3; index += 2; continue; } if ([ "\\", "\"", "$", "`", "\n", "\r" ].includes(next)) { if (next !== "\n" && next !== "\r") appendDecodedText(decoded, next, sourceOffset + 2); else decoded.sourceOffsets[decoded.value.length] = sourceOffset + 2; index += 1; continue; } } appendDecodedText(decoded, ch, sourceOffset + 1); } return decoded; } function decodeDoubleQuotedText(text) { return decodeDoubleQuotedTextWithOffsets(text).value; } const ANSI_C_SIMPLE_ESCAPES = { "'": "'", "\"": "\"", "?": "?", "\\": "\\", a: "\x07", b: "\b", e: "\x1B", E: "\x1B", f: "\f", n: "\n", r: "\r", t: " ", v: "\v" }; function decodeAnsiCStringWithOffsets(text) { const hasQuotes = text.startsWith("$'") && text.endsWith("'"); const bodyStart = hasQuotes ? 2 : 0; const body = hasQuotes ? text.slice(2, -1) : text; const decoded = { value: "", sourceOffsets: [bodyStart] }; for (let index = 0; index < body.length; index += 1) { const ch = body[index]; const sourceOffset = bodyStart + index; if (ch !== "\\") { appendDecodedText(decoded, ch, sourceOffset + 1); continue; } const next = body[index + 1]; if (next === void 0) { appendDecodedText(decoded, "\\", sourceOffset + 1); continue; } const simple = ANSI_C_SIMPLE_ESCAPES[next]; if (simple !== void 0) { appendDecodedText(decoded, simple, sourceOffset + 2); index += 1; continue; } if (next === "x") { const hex = body.slice(index + 2).match(/^[0-9A-Fa-f]{1,2}/)?.[0] ?? ""; if (hex) { appendDecodedText(decoded, String.fromCodePoint(Number.parseInt(hex, 16)), sourceOffset + 2 + hex.length); index += 1 + hex.length; continue; } } if (next === "u" || next === "U") { const maxLength = next === "u" ? 4 : 8; const hex = body.slice(index + 2).match(new RegExp(`^[0-9A-Fa-f]{1,${maxLength}}`))?.[0] ?? ""; if (hex) { const codePoint = Number.parseInt(hex, 16); try { appendDecodedText(decoded, String.fromCodePoint(codePoint), sourceOffset + 2 + hex.length); } catch { appendDecodedText(decoded, `\\${next}${hex}`, sourceOffset + 2 + hex.length); } index += 1 + hex.length; continue; } } if (/^[0-7]$/.test(next)) { const octal = body.slice(index + 1).match(/^[0-7]{1,3}/)?.[0] ?? ""; if (octal) { appendDecodedText(decoded, String.fromCodePoint(Number.parseInt(octal, 8)), sourceOffset + 1 + octal.length); index += octal.length; continue; } } appendDecodedText(decoded, next, sourceOffset + 2); index += 1; } return decoded; } function decodeAnsiCString(text) { return decodeAnsiCStringWithOffsets(text).value; } function hasDynamicWordPart(node) { return DYNAMIC_WORD_NODE_TYPES.has(node.type) || namedChildren(node).some((child) => hasDynamicWordPart(child)); } function shellWordValue(node) { if (DYNAMIC_WORD_NODE_TYPES.has(node.type)) return { kind: "dynamic", value: node.text }; if (node.type !== "command_name" && node.type !== "concatenation" && namedChildren(node).some((child) => hasDynamicWordPart(child))) return { kind: "dynamic", value: node.type === "string" ? decodeDoubleQuotedText(node.text) : node.text }; switch (node.type) { case "command_name": { const parts = namedChildren(node); if (parts.length === 0) return hasUnescapedDynamicPattern(node.text) ? { kind: "dynamic", value: decodeUnquotedShellText(node.text) } : { kind: "literal", value: decodeUnquotedShellText(node.text) }; let value = ""; for (const part of parts) { const partValue = shellWordValue(part); value += partValue.value; if (partValue.kind !== "literal") return { kind: "dynamic", value }; } return { kind: "literal", value }; } case "word": return hasUnescapedDynamicPattern(node.text) ? { kind: "dynamic", value: decodeUnquotedShellText(node.text) } : { kind: "literal", value: decodeUnquotedShellText(node.text) }; case "raw_string": return { kind: "literal", value: node.text.slice(1, -1) }; case "string": return { kind: "literal", value: decodeDoubleQuotedText(node.text) }; case "ansi_c_string": return { kind: "literal", value: decodeAnsiCString(node.text) }; case "concatenation": { if (hasUnescapedDynamicPattern(node.text)) return { kind: "dynamic", value: decodeUnquotedShellText(node.text) }; let value = ""; let dynamic = false; for (const child of namedChildren(node)) { const childValue = shellWordValue(child); value += childValue.value; if (childValue.kind !== "literal") dynamic = true; } return dynamic ? { kind: "dynamic", value } : { kind: "literal", value }; } default: return namedChildren(node).some((child) => shellWordValue(child).kind === "dynamic") ? { kind: "dynamic", value: decodeUnquotedShellText(node.text) } : { kind: "literal", value: decodeUnquotedShellText(node.text) }; } } function commandNameNode(node) { return node.childForFieldName("name") ?? namedChildren(node).find((child) => child.type === "command_name") ?? null; } function argvFromCommand(node, nameNode, state) { if (hasEscapedLineContinuation(nameNode.text) || hasExecutableLineContinuation(node.text)) return null; const executable = shellWordValue(nameNode); if (executable.kind !== "literal") return null; const skipped = new Set([nameNode, ...namedChildren(nameNode)]); const argv = [executable.value]; const argumentsList = []; const dynamicArguments = []; for (const child of namedChildren(node)) { if (skipped.has(child) || child.type === "command_name" || child.type === "variable_assignment" || !COMMAND_ARGUMENT_NODE_TYPES.has(child.type)) continue; const value = shellWordValue(child); const argument = argumentFromNode(argv.length, child, value, state.spanBase); argumentsList.push(argument); if (value.kind === "dynamic") dynamicArguments.push({ index: argument.index, text: argument.text, value: argument.value, span: argument.span }); argv.push(value.value); } return { argv, arguments: argumentsList, dynamicArguments }; } function firstShellToken(text) { return text.trimStart().match(/^\S+/)?.[0] ?? ""; } function argvFromDeclarationCommand(node, state) { const executable = firstShellToken(node.text); if (!executable) return null; const argv = [executable]; const argumentsList = []; const dynamicArguments = []; for (const child of namedChildren(node)) { if (!COMMAND_ARGUMENT_NODE_TYPES.has(child.type) && child.type !== "variable_assignment") continue; const value = shellWordValue(child); const argument = argumentFromNode(argv.length, child, value, state.spanBase); argumentsList.push(argument); if (value.kind === "dynamic") dynamicArguments.push({ index: argument.index, text: argument.text, value: argument.value, span: argument.span }); argv.push(value.value); } return { argv, arguments: argumentsList, dynamicArguments }; } function appendTestCommandArguments(node, argv, argumentsList, dynamicArguments, state) { if (node.type === "test_operator" || COMMAND_ARGUMENT_NODE_TYPES.has(node.type)) { const value = shellWordValue(node); const argument = argumentFromNode(argv.length, node, value, state.spanBase); argumentsList.push(argument); if (value.kind === "dynamic") dynamicArguments.push({ index: argument.index, text: argument.text, value: argument.value, span: argument.span }); argv.push(value.value); return; } for (const child of namedChildren(node)) appendTestCommandArguments(child, argv, argumentsList, dynamicArguments, state); } function argvFromTestCommand(node, state) { const trimmed = node.text.trimStart(); const executable = trimmed.startsWith("[[") ? "[[" : trimmed.startsWith("[") ? "[" : ""; if (!executable) return null; const argv = [executable]; const argumentsList = []; const dynamicArguments = []; for (const child of namedChildren(node)) appendTestCommandArguments(child, argv, argumentsList, dynamicArguments, state); return { argv, arguments: argumentsList, dynamicArguments }; } function isCommandLikeNode(node) { return node.type === "command" || node.type === "declaration_command" || node.type === "test_command"; } function recordShape(node, output) { if ((node.type === "program" || node.type === "list") && (hasDirectChildType(node, ";") || namedChildren(node).filter(isCommandLikeNode).length > 1)) output.shapes.add("sequence"); if (hasDirectChildType(node, "&")) output.shapes.add("background"); if (node.type === "pipeline") output.shapes.add("pipeline"); if (node.type === "list") { if (hasDirectChildType(node, "&&")) output.shapes.add("and"); if (hasDirectChildType(node, "||")) output.shapes.add("or"); } if (node.type === "if_statement") output.shapes.add("if"); if (node.type === "for_statement") output.shapes.add("for"); if (node.type === "while_statement") output.shapes.add("while"); if (node.type === "case_statement") output.shapes.add("case"); if (node.type === "subshell") output.shapes.add("subshell"); if (node.type === "compound_statement") output.shapes.add("group"); } function shellCommandFlag(argv, startIndex) { const shell = normalizeExecutableToken(argv[startIndex - 1] ?? argv[0] ?? ""); for (let index = startIndex; index < argv.length; index += 1) { const token = argv[index]?.trim(); if (!token) continue; if (token === "--") break; const lower = token.toLowerCase(); if (shell === "cmd") { if (lower === "/c" || lower === "/k") return { flag: token, index }; continue; } if (shell === "powershell" || shell === "pwsh") { if (lower === "-c" || lower === "-command" || lower === "--command" || lower === "-encodedcommand" || lower === "-enc" || lower === "-e" || lower === "-f" || lower === "-file") return { flag: token, index }; continue; } if (lower === "-c" || lower === "--command") return { flag: token, index }; if (token.startsWith("-") && !token.startsWith("--") && lower.slice(1).includes("c")) return { flag: token, index }; } return null; } function canParseShellWrapperPayload(transportArgv, commandFlag) { const shellExecutable = normalizeExecutableToken(transportArgv[0] ?? ""); if (!PARSEABLE_SHELL_WRAPPERS.has(shellExecutable)) return false; const lowerFlag = commandFlag?.toLowerCase() ?? ""; return lowerFlag === "-c" || lowerFlag === "--command" || /^-[^-]*c[^-]*$/i.test(lowerFlag); } function isDynamicPayload(payload, dynamicArguments) { return dynamicArguments.some((argument) => argument.value === payload); } function payloadBaseFromArgument(argument, payload) { const payloadOffset = argument.value.indexOf(payload); if (payloadOffset < 0) return null; const rawPayloadOffset = argument.decodedSourceOffsets[payloadOffset]; if (rawPayloadOffset === void 0) return null; const prefix = argument.text.slice(0, rawPayloadOffset); return { startIndex: argument.span.startIndex + rawPayloadOffset, startPosition: advancePosition(argument.span.startPosition, prefix), mapOffset(offset) { const mappedRawOffset = argument.decodedSourceOffsets[payloadOffset + offset] ?? rawPayloadOffset + offset; return { index: argument.span.startIndex + mappedRawOffset, position: advancePosition(argument.span.startPosition, argument.text.slice(0, mappedRawOffset)) }; } }; } function payloadBaseFromArguments(payload, argumentsList) { const exactArgument = argumentsList.find((argument) => argument.value === payload); if (exactArgument) return payloadBaseFromArgument(exactArgument, payload); for (const argument of argumentsList) { const base = payloadBaseFromArgument(argument, payload); if (base) return base; } return null; } function shellWrapperPayloadForParsing(argv, argumentsList, dynamicArguments) { const shellWrapper = extractShellWrapperCommand(argv); const payload = shellWrapper.command ?? extractShellWrapperInlineCommand(argv); if (!shellWrapper.isWrapper || !payload || isDynamicPayload(payload, dynamicArguments)) return null; const spanBase = payloadBaseFromArguments(payload, argumentsList); if (!spanBase) return null; const transportArgv = resolveShellWrapperTransportArgv(argv) ?? argv; if (!canParseShellWrapperPayload(transportArgv, (shellCommandFlag(transportArgv, 1) ?? shellCommandFlag(argv, 1))?.flag ?? null)) return null; return { command: payload, spanBase }; } function recordInlineEvalRisk(inlineEval, text, span, output) { output.risks.push({ kind: "inline-eval", command: inlineEval.normalizedExecutable, flag: inlineEval.flag, text, span }); } function recordDynamicArgumentRisks(command, dynamicArguments, output) { for (const argument of dynamicArguments) output.risks.push({ kind: "dynamic-argument", command, argumentIndex: argument.index, text: argument.text, span: argument.span }); } function recordCommandRisks(argv, dynamicArguments, text, span, output) { const executable = argv[0]; if (!executable) return; const normalizedExecutable = normalizeExecutableToken(executable); recordDynamicArgumentRisks(normalizedExecutable, dynamicArguments, output); const inlineEval = detectInlineEvalArgv(argv) ?? detectCarrierInlineEvalArgv(argv); if (inlineEval) recordInlineEvalRisk(inlineEval, text, span, output); const shellWrapper = extractShellWrapperCommand(argv); const shellWrapperPayload = shellWrapper.command ?? extractShellWrapperInlineCommand(argv); if (shellWrapper.isWrapper && shellWrapperPayload) { const transportArgv = resolveShellWrapperTransportArgv(argv) ?? argv; const shellExecutable = transportArgv[0] ?? executable; const commandFlag = shellCommandFlag(transportArgv, 1) ?? shellCommandFlag(argv, 1); if (isShellWrapperExecutable(executable)) output.risks.push({ kind: "shell-wrapper", executable: shellExecutable, flag: commandFlag?.flag ?? "-c", payload: shellWrapperPayload, text, span }); else output.risks.push({ kind: "shell-wrapper-through-carrier", command: normalizedExecutable, text, span }); } for (const carrier of detectCommandCarrierArgv(argv)) output.risks.push({ kind: "command-carrier", command: carrier.command, flag: carrier.flag, text, span }); if (normalizedExecutable === "eval") output.risks.push({ kind: "eval", text, span }); if (SOURCE_EXECUTABLES.has(normalizedExecutable)) output.risks.push({ kind: "source", command: normalizedExecutable, text, span }); if (normalizedExecutable === "alias") output.risks.push({ kind: "alias", text, span }); const carrierShellWrapper = !shellWrapper.isWrapper ? detectShellWrapperThroughCarrierArgv(argv, shellCommandFlag) : null; if (carrierShellWrapper) output.risks.push({ kind: "shell-wrapper-through-carrier", command: carrierShellWrapper, text, span }); const carriedShellBuiltin = detectCarriedShellBuiltinArgv(argv); if (carriedShellBuiltin?.kind === "eval") output.risks.push({ kind: "eval", text, span }); else if (carriedShellBuiltin?.kind === "source") output.risks.push({ kind: "source", command: carriedShellBuiltin.command, text, span }); } async function walk(node, output, context, state) { recordShape(node, output); const span = spanFromNode(node, state.spanBase); let childContext = context; if (node.type === "program" && hasEscapedLineContinuation(node.text)) output.risks.push({ kind: "line-continuation", text: node.text, span }); if (node.type === "function_definition") { const nameNode = node.childForFieldName("name"); output.risks.push({ kind: "function-definition", name: nameNode?.text ?? "", text: node.text, span }); childContext = "function-definition"; } else if (node.type === "command_substitution") { output.risks.push({ kind: "command-substitution", text: node.text, span }); childContext = "command-substitution"; } else if (node.type === "process_substitution") { output.risks.push({ kind: "process-substitution", text: node.text, span }); childContext = "process-substitution"; } else if (node.type === "heredoc_redirect") output.risks.push({ kind: "heredoc", text: node.text, span }); else if (node.type === "herestring_redirect") output.risks.push({ kind: "here-string", text: node.text, span }); else if (node.type === "file_redirect") output.risks.push({ kind: "redirect", text: node.text, span }); else if (node.type === "ERROR") output.risks.push({ kind: "syntax-error", text: node.text, span }); if (node.type === "command" || node.type === "declaration_command" || node.type === "test_command") { const nameNode = node.type === "command" ? commandNameNode(node) : null; const parsed = node.type === "command" ? nameNode ? argvFromCommand(node, nameNode, state) : null : node.type === "declaration_command" ? argvFromDeclarationCommand(node, state) : argvFromTestCommand(node, state); if (node.type === "command" && nameNode && !parsed) output.risks.push({ kind: "dynamic-executable", text: nameNode.text, span: spanFromNode(nameNode, state.spanBase) }); else if (parsed) { const step = { context, executable: parsed.argv[0] ?? "", argv: parsed.argv, text: node.text, span, executableSpan: nameNode !== null ? spanFromNode(nameNode, state.spanBase) : parsed.arguments[0]?.span ?? span }; if (step.executable) { output.commands.push(step); recordCommandRisks(parsed.argv, parsed.dynamicArguments, node.text, span, output); const wrapperPayload = shellWrapperPayloadForParsing(parsed.argv, parsed.arguments, parsed.dynamicArguments); if (wrapperPayload && state.wrapperPayloadDepth < MAX_WRAPPER_PAYLOAD_DEPTH) { const wrapperTree = await parseBashForCommandExplanation(wrapperPayload.command); const wrapperSpanBase = spanBaseForParserSource(wrapperPayload.command, wrapperTree.rootNode, wrapperPayload.spanBase); try { if (wrapperTree.rootNode.hasError) { output.hasParseError = true; output.risks.push({ kind: "syntax-error", text: wrapperPayload.command, span: spanFromNode(wrapperTree.rootNode, wrapperSpanBase) }); } await walk(wrapperTree.rootNode, output, "wrapper-payload", { wrapperPayloadDepth: state.wrapperPayloadDepth + 1, spanBase: wrapperSpanBase }); } finally { wrapperTree.delete(); } } } } } for (const child of namedChildren(node)) await walk(child, output, childContext, state); } /** Parses a shell command into command steps, shapes, risks, and source spans. */ async function explainShellCommand(source) { const tree = await parseBashForCommandExplanation(source); try { const spanBase = spanBaseForParserSource(source, tree.rootNode, ROOT_SPAN_BASE); const output = { shapes: /* @__PURE__ */ new Set(), commands: [], risks: [], hasParseError: tree.rootNode.hasError }; await walk(tree.rootNode, output, "top-level", { wrapperPayloadDepth: 0, spanBase }); const topLevelCommands = output.commands.filter((command) => command.context === "top-level"); return { ok: !output.hasParseError, source, shapes: [...output.shapes], topLevelCommands, nestedCommands: output.commands.filter((command) => command.context !== "top-level"), risks: output.risks }; } finally { tree.delete(); } } //#endregion //#region src/infra/command-explainer/format.ts const POSIX_COMMAND_HIGHLIGHT_SHELLS = POSIX_SHELL_WRAPPERS; function spanToCommandSpan(span) { if (!Number.isSafeInteger(span.startIndex) || !Number.isSafeInteger(span.endIndex)) return null; if (span.startIndex < 0 || span.endIndex <= span.startIndex) return null; return { startIndex: span.startIndex, endIndex: span.endIndex }; } function isUnsupportedShellWrapperArgv(argv) { const executable = (resolveShellWrapperTransportArgv([...argv]) ?? argv)[0]; if (!executable) return false; const normalizedExecutable = normalizeExecutableToken(executable); return isShellWrapperExecutable(normalizedExecutable) && !POSIX_COMMAND_HIGHLIGHT_SHELLS.has(normalizedExecutable); } function hasUnsupportedShellWrapper(explanation) { return explanation.topLevelCommands.some((command) => isUnsupportedShellWrapperArgv(command.argv)); } /** Converts a parsed command explanation into source spans suitable for approval UI. */ function formatCommandSpans(explanation) { if (hasUnsupportedShellWrapper(explanation)) return []; const commandSpans = []; for (const command of [...explanation.topLevelCommands, ...explanation.nestedCommands]) { const commandSpan = spanToCommandSpan(command.executableSpan); if (commandSpan) commandSpans.push(commandSpan); } return commandSpans; } //#endregion //#region src/agents/bash-tools.exec-approval-request.runtime.ts /** * Lazy runtime for exec approval command highlighting. * Kept separate so importing approval request code does not load the command * explainer until command spans are explicitly requested. */ /** Resolve command spans used to highlight exec approval prompts. */ async function resolveExecApprovalCommandSpans(command) { const commandSpans = formatCommandSpans(await explainShellCommand(command)); return commandSpans.length > 0 ? commandSpans : void 0; } //#endregion export { resolveExecApprovalCommandSpans };