diff --git a/CHANGELOG.md b/CHANGELOG.md index 9be651fc30..0d4465124d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck. - macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR. - Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug. +- Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr. ## 2026.2.12 diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 6ccebc2e0d..26c50c1245 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -164,6 +164,68 @@ describe("exec approvals shell parsing", () => { expect(res.segments[0]?.argv[0]).toBe("echo"); }); + it("rejects input redirection (<)", () => { + const res = analyzeShellCommand({ command: "cat < input.txt" }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("unsupported shell token: <"); + }); + + it("rejects output redirection (>)", () => { + const res = analyzeShellCommand({ command: "echo ok > output.txt" }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("unsupported shell token: >"); + }); + + it("allows heredoc operator (<<)", () => { + const res = analyzeShellCommand({ command: "/usr/bin/tee /tmp/file << 'EOF'" }); + expect(res.ok).toBe(true); + expect(res.segments[0]?.argv[0]).toBe("/usr/bin/tee"); + }); + + it("allows heredoc without space before delimiter", () => { + const res = analyzeShellCommand({ command: "/usr/bin/tee /tmp/file < { + const res = analyzeShellCommand({ command: "/usr/bin/cat <<-DELIM" }); + expect(res.ok).toBe(true); + expect(res.segments[0]?.argv[0]).toBe("/usr/bin/cat"); + }); + + it("allows heredoc in pipeline", () => { + const res = analyzeShellCommand({ command: "/usr/bin/cat << 'EOF' | /usr/bin/grep pattern" }); + expect(res.ok).toBe(true); + expect(res.segments).toHaveLength(2); + expect(res.segments[0]?.argv[0]).toBe("/usr/bin/cat"); + expect(res.segments[1]?.argv[0]).toBe("/usr/bin/grep"); + }); + + it("allows multiline heredoc body", () => { + const res = analyzeShellCommand({ + command: "/usr/bin/tee /tmp/file << 'EOF'\nline one\nline two\nEOF", + }); + expect(res.ok).toBe(true); + expect(res.segments[0]?.argv[0]).toBe("/usr/bin/tee"); + }); + + it("allows multiline heredoc body with strip-tabs operator (<<-)", () => { + const res = analyzeShellCommand({ + command: "/usr/bin/cat <<-EOF\n\tline one\n\tline two\n\tEOF", + }); + expect(res.ok).toBe(true); + expect(res.segments[0]?.argv[0]).toBe("/usr/bin/cat"); + }); + + it("rejects multiline commands without heredoc", () => { + const res = analyzeShellCommand({ + command: "/usr/bin/echo first line\n/usr/bin/echo second line", + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("unsupported shell token: \n"); + }); + it("rejects windows shell metacharacters", () => { const res = analyzeShellCommand({ command: "ping 127.0.0.1 -n 1 & whoami", diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 05787b1a3e..ea71256bca 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -636,31 +636,77 @@ function isDoubleQuoteEscape(next: string | undefined): next is string { return Boolean(next && DOUBLE_QUOTE_ESCAPES.has(next)); } -type IteratorAction = "split" | "skip" | "include" | { reject: string }; +function splitShellPipeline(command: string): { ok: boolean; reason?: string; segments: string[] } { + type HeredocSpec = { + delimiter: string; + stripTabs: boolean; + }; -/** - * Iterates through a command string while respecting shell quoting rules. - * The callback receives each character and the next character, and returns an action: - * - "split": push current buffer as a segment and start a new one - * - "skip": skip this character (and optionally the next via skip count) - * - "include": add this character to the buffer - * - { reject: reason }: abort with an error - */ -function iterateQuoteAware( - command: string, - onChar: (ch: string, next: string | undefined, index: number) => IteratorAction, -): { ok: true; parts: string[]; hasSplit: boolean } | { ok: false; reason: string } { - const parts: string[] = []; + const parseHeredocDelimiter = ( + source: string, + start: number, + ): { delimiter: string; end: number } | null => { + let i = start; + while (i < source.length && (source[i] === " " || source[i] === "\t")) { + i += 1; + } + if (i >= source.length) { + return null; + } + + const first = source[i]; + if (first === "'" || first === '"') { + const quote = first; + i += 1; + let delimiter = ""; + while (i < source.length) { + const ch = source[i]; + if (ch === "\n" || ch === "\r") { + return null; + } + if (quote === '"' && ch === "\\" && i + 1 < source.length) { + delimiter += source[i + 1]; + i += 2; + continue; + } + if (ch === quote) { + return { delimiter, end: i + 1 }; + } + delimiter += ch; + i += 1; + } + return null; + } + + let delimiter = ""; + while (i < source.length) { + const ch = source[i]; + if (/\s/.test(ch) || ch === "|" || ch === "&" || ch === ";" || ch === "<" || ch === ">") { + break; + } + delimiter += ch; + i += 1; + } + if (!delimiter) { + return null; + } + return { delimiter, end: i }; + }; + + const segments: string[] = []; let buf = ""; let inSingle = false; let inDouble = false; let escaped = false; - let hasSplit = false; + let emptySegment = false; + const pendingHeredocs: HeredocSpec[] = []; + let inHeredocBody = false; + let heredocLine = ""; const pushPart = () => { const trimmed = buf.trim(); if (trimmed) { - parts.push(trimmed); + segments.push(trimmed); } buf = ""; }; @@ -669,14 +715,38 @@ function iterateQuoteAware( const ch = command[i]; const next = command[i + 1]; + if (inHeredocBody) { + if (ch === "\n" || ch === "\r") { + const current = pendingHeredocs[0]; + if (current) { + const line = current.stripTabs ? heredocLine.replace(/^\t+/, "") : heredocLine; + if (line === current.delimiter) { + pendingHeredocs.shift(); + } + } + heredocLine = ""; + if (pendingHeredocs.length === 0) { + inHeredocBody = false; + } + if (ch === "\r" && next === "\n") { + i += 1; + } + } else { + heredocLine += ch; + } + continue; + } + if (escaped) { buf += ch; escaped = false; + emptySegment = false; continue; } if (!inSingle && !inDouble && ch === "\\") { escaped = true; buf += ch; + emptySegment = false; continue; } if (inSingle) { @@ -684,6 +754,7 @@ function iterateQuoteAware( inSingle = false; } buf += ch; + emptySegment = false; continue; } if (inDouble) { @@ -691,93 +762,113 @@ function iterateQuoteAware( buf += ch; buf += next; i += 1; + emptySegment = false; continue; } if (ch === "$" && next === "(") { - return { ok: false, reason: "unsupported shell token: $()" }; + return { ok: false, reason: "unsupported shell token: $()", segments: [] }; } if (ch === "`") { - return { ok: false, reason: "unsupported shell token: `" }; + return { ok: false, reason: "unsupported shell token: `", segments: [] }; } if (ch === "\n" || ch === "\r") { - return { ok: false, reason: "unsupported shell token: newline" }; + return { ok: false, reason: "unsupported shell token: newline", segments: [] }; } if (ch === '"') { inDouble = false; } buf += ch; + emptySegment = false; continue; } if (ch === "'") { inSingle = true; buf += ch; + emptySegment = false; continue; } if (ch === '"') { inDouble = true; buf += ch; + emptySegment = false; continue; } - const action = onChar(ch, next, i); - if (typeof action === "object" && "reject" in action) { - return { ok: false, reason: action.reject }; - } - if (action === "split") { - pushPart(); - hasSplit = true; + if ((ch === "\n" || ch === "\r") && pendingHeredocs.length > 0) { + inHeredocBody = true; + heredocLine = ""; + if (ch === "\r" && next === "\n") { + i += 1; + } continue; } - if (action === "skip") { - continue; - } - buf += ch; - } - if (escaped || inSingle || inDouble) { - return { ok: false, reason: "unterminated shell quote/escape" }; - } - pushPart(); - return { ok: true, parts, hasSplit }; -} - -function splitShellPipeline(command: string): { ok: boolean; reason?: string; segments: string[] } { - let emptySegment = false; - const result = iterateQuoteAware(command, (ch, next) => { if (ch === "|" && next === "|") { - return { reject: "unsupported shell token: ||" }; + return { ok: false, reason: "unsupported shell token: ||", segments: [] }; } if (ch === "|" && next === "&") { - return { reject: "unsupported shell token: |&" }; + return { ok: false, reason: "unsupported shell token: |&", segments: [] }; } if (ch === "|") { emptySegment = true; - return "split"; + pushPart(); + continue; } if (ch === "&" || ch === ";") { - return { reject: `unsupported shell token: ${ch}` }; + return { ok: false, reason: `unsupported shell token: ${ch}`, segments: [] }; + } + if (ch === "<" && next === "<") { + buf += "<<"; + emptySegment = false; + i += 1; + + let scanIndex = i + 1; + let stripTabs = false; + if (command[scanIndex] === "-") { + stripTabs = true; + buf += "-"; + scanIndex += 1; + } + + const parsed = parseHeredocDelimiter(command, scanIndex); + if (parsed) { + pendingHeredocs.push({ delimiter: parsed.delimiter, stripTabs }); + buf += command.slice(scanIndex, parsed.end); + i = parsed.end - 1; + } + continue; } if (DISALLOWED_PIPELINE_TOKENS.has(ch)) { - return { reject: `unsupported shell token: ${ch}` }; + return { ok: false, reason: `unsupported shell token: ${ch}`, segments: [] }; } if (ch === "$" && next === "(") { - return { reject: "unsupported shell token: $()" }; + return { ok: false, reason: "unsupported shell token: $()", segments: [] }; } + buf += ch; emptySegment = false; - return "include"; - }); - - if (!result.ok) { - return { ok: false, reason: result.reason, segments: [] }; } - if (emptySegment || result.parts.length === 0) { + + if (inHeredocBody && pendingHeredocs.length > 0) { + const current = pendingHeredocs[0]; + const line = current.stripTabs ? heredocLine.replace(/^\t+/, "") : heredocLine; + if (line === current.delimiter) { + pendingHeredocs.shift(); + } + } + + if (escaped || inSingle || inDouble) { + return { ok: false, reason: "unterminated shell quote/escape", segments: [] }; + } + + pushPart(); + if (emptySegment || segments.length === 0) { return { ok: false, - reason: result.parts.length === 0 ? "empty command" : "empty pipeline segment", + reason: segments.length === 0 ? "empty command" : "empty pipeline segment", segments: [], }; } - return { ok: true, segments: result.parts }; + return { ok: true, segments }; } function findWindowsUnsupportedToken(command: string): string | null {