diff --git a/src/agents/bash-tools.exec.script-preflight.test.ts b/src/agents/bash-tools.exec.script-preflight.test.ts index 86bf7d33c28..7f202ca9775 100644 --- a/src/agents/bash-tools.exec.script-preflight.test.ts +++ b/src/agents/bash-tools.exec.script-preflight.test.ts @@ -783,3 +783,32 @@ describeWin("exec script preflight on windows path syntax", () => { }); }); }); + +describe("exec interpreter heuristics ReDoS guard", () => { + it("does not hang on long commands with VAR=value assignments and whitespace-heavy text", async () => { + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + // Simulate a heredoc with HTML content after a VAR= assignment — the pattern + // that triggers catastrophic backtracking when .* is used instead of \S* + const htmlBlock = '
'.repeat(50); + const command = `ACCESS_TOKEN=$(curl -s https://api.example.com/token)\ncat > /tmp/out.html << 'EOF'\n${htmlBlock}\nEOF`; + + const start = Date.now(); + // The command itself will fail — we only care that the interpreter + // heuristics analysis completes without hanging. + try { + await Promise.race([ + tool.execute("redos-guard", { command }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("ReDoS: regex hung for >5s")), 5000), + ), + ]); + } catch (e) { + // Any error EXCEPT the timeout is acceptable — it means the regex finished + if (e instanceof Error && e.message.includes("ReDoS")) { + throw e; + } + } + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(5000); + }); +}); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index ea2bece30e3..3807ac13919 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -418,11 +418,11 @@ function analyzeInterpreterHeuristicsFromUnquoted(raw: string): { hasScriptHint: boolean; } { const hasPython = - /(?:^|\s|(?\n\r`$])/i.test( + /(?:^|\s|(?\n\r`$])/i.test( raw, ); const hasNode = - /(?:^|\s|(?\n\r`$])/i.test( + /(?:^|\s|(?\n\r`$])/i.test( raw, ); const hasProcessSubstitution = /(?\(/u.test(raw); @@ -622,7 +622,7 @@ function shouldFailClosedInterpreterPreflight(command: string): { }; const hasInterpreterInvocationInSegment = (rawSegment: string): boolean => { const segment = extractUnquotedShellText(rawSegment) ?? rawSegment; - return /^\s*(?:(?:if|then|do|elif|else|while|until|time)\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=.*\s+)*(?:python(?:3(?:\.\d+)?)?|node)(?=$|[\s|&;()<>\n\r`$])/i.test( + return /^\s*(?:(?:if|then|do|elif|else|while|until|time)\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:python(?:3(?:\.\d+)?)?|node)(?=$|[\s|&;()<>\n\r`$])/i.test( segment, ); };