fix(exec): allow heredoc operator (<<) in allowlist security mode (#13811)

* fix(exec): allow heredoc operator (<<) in allowlist security mode

* fix: allow multiline heredoc parsing in exec approvals (#13811) (thanks @mcaxtr)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Marcus Castro
2026-02-13 00:41:51 -03:00
committed by GitHub
parent 7c25696ab0
commit e90caa66d8
3 changed files with 209 additions and 55 deletions

View File

@@ -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

View File

@@ -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 <<EOF" });
expect(res.ok).toBe(true);
expect(res.segments[0]?.argv[0]).toBe("/usr/bin/tee");
});
it("allows heredoc with strip-tabs operator (<<-)", () => {
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",

View File

@@ -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 {