diff --git a/src/adapters/exec-tool-result-normalize.ts b/src/adapters/exec-tool-result-normalize.ts index c31e1c76eff..632d671703e 100644 --- a/src/adapters/exec-tool-result-normalize.ts +++ b/src/adapters/exec-tool-result-normalize.ts @@ -20,8 +20,11 @@ * `function_call_output` is parsed with `isError: false`. Cursor combines this set with * `isFailedEmptyExecWrapper` below for Computer Use, where a failed wrapper is separately marked * `isError`. + * + * `(?=(X))\1` pins each wildcard run to its maximal match — without it, adjacent `\n+`/`\s*` + * runs can repartition a newline block combinatorially (the ReDoS shape this had before). */ -export const EMPTY_EXEC_OUTPUT_REGEX = /^(?:(?:Script completed|Command finished|Execution finished)[^\n]*\n+)?(?:Wall time[^\n]*\n+)?(?:Output:\s*)?(?:)?\s*$/; +export const EMPTY_EXEC_OUTPUT_REGEX = /^(?:(?:Script completed|Command finished|Execution finished)(?=([^\n]*))\1(?=(\n+))\2)?(?:Wall time(?=([^\n]*))\3(?=(\n+))\4)?(?:Output:(?=(\s*))\5)?(?:)?(?=(\s*))\6$/; function skipFailedWrapperBlankSeparators(text: string, start: number): number { let index = start; diff --git a/src/lab/events/limits.ts b/src/lab/events/limits.ts index 1447801a15e..e4a37c44633 100644 --- a/src/lab/events/limits.ts +++ b/src/lab/events/limits.ts @@ -32,7 +32,7 @@ const FORBIDDEN_EXACT_KEYS = new Set([ ]); const RAW_POSIX_PATH_RE = - /(?:^|[^A-Za-z0-9._~/])\/(?:(?=$|[^A-Za-z0-9._~/])|(?!\/)(?![ \t\r\n])(?:\/|[^/\0\r\n]+)+\/?(?=$|[^A-Za-z0-9._~/]))/u; + /(?:^|[^A-Za-z0-9._~/])\/(?:(?=$|[^A-Za-z0-9._~/])|(?!\/)(?![ \t\r\n])[^/\0\r\n]+(?:\/+[^/\0\r\n]+)*\/*(?=$|[^A-Za-z0-9._~/]))/u; const ASCII_URL_WHITESPACE_RE = /[\t\r\n]/g; const FILE_URI_RE = /(?:^|[^A-Za-z0-9+.-])file:/i; diff --git a/tests/adapters/exec-tool-result-normalize.test.ts b/tests/adapters/exec-tool-result-normalize.test.ts index 953e769aace..e5aef8215f7 100644 --- a/tests/adapters/exec-tool-result-normalize.test.ts +++ b/tests/adapters/exec-tool-result-normalize.test.ts @@ -5,6 +5,7 @@ import { parseRequest } from "../../src/responses/parser"; import { CODE_MODE_HOST_CONTRACT_SENTENCE, CODE_MODE_HOST_FAILURE_GUIDANCE, + EMPTY_EXEC_OUTPUT_REGEX, annotateCodeModeHostFailure, } from "../../src/adapters/exec-tool-result-normalize"; @@ -112,3 +113,43 @@ describe("code-mode host failure annotation", () => { }); }); +describe("empty exec output wrapper detection", () => { + test("recognizes each optional section and their combinations", () => { + for (const text of [ + "Script completed\nWall time 0.1 seconds\nOutput:\n", + "Script completed\nWall time 0.1 seconds\nOutput:\n\n", + "Command finished\nOutput:\n", + "Execution finished\n\n\nWall time 1s\n\nOutput:\n\n\n\n", + "Wall time 5s\n", + "Wall time 5s\nOutput:\n", + "Output:", + "Output:\n\n\n", + "", + "\n\n\n", + ]) { + expect(EMPTY_EXEC_OUTPUT_REGEX.test(text)).toBe(true); + } + for (const text of [ + "Script completed", + "Script completed\nreal output\n", + "Script failed\nOutput:\n", + "Output: hi\n", + "text\n", + "x", + "Script completed\nWall time\nOutput:\nx", + "Wall time\n trailing", + ]) { + expect(EMPTY_EXEC_OUTPUT_REGEX.test(text)).toBe(false); + } + }); + + // The previous pattern let adjacent `\n+`/`\s*` quantifiers repartition a newline block + // combinatorially; these inputs keep that a timeout-scale regression rather than a silent one. + test("stays linear on pathological whitespace runs", () => { + const newlines = "\n".repeat(200_000); + expect(EMPTY_EXEC_OUTPUT_REGEX.test(`Script completed\n${newlines}!`)).toBe(false); + expect(EMPTY_EXEC_OUTPUT_REGEX.test(`Output:${newlines}`)).toBe(true); + expect(EMPTY_EXEC_OUTPUT_REGEX.test(`Script completed\nWall time x\n${newlines}trailing`)).toBe(false); + }); +}); + diff --git a/tests/lab/lab-post-merge-hardening.test.ts b/tests/lab/lab-post-merge-hardening.test.ts index 0e8660a448a..45a63b5b252 100644 --- a/tests/lab/lab-post-merge-hardening.test.ts +++ b/tests/lab/lab-post-merge-hardening.test.ts @@ -222,6 +222,30 @@ test("event privacy admission rejects raw filesystem path bypass forms", () => { } }); +test("event privacy admission stays linear on pathological path strings", () => { + // RAW_POSIX_PATH_RE once alternated `\/` with `[^/]+` under a shared `+`; long segment runs + // were repartitioned combinatorially. Keep these just under the 4 KiB field cap so a + // backtracking regression surfaces as a timeout rather than a wrong verdict. + const deep = `cwd=/${"a/".repeat(2000)}`; + try { + enforceEventStructureLimits({ detail: deep }); + throw new Error("expected raw_path rejection for a long segment chain"); + } catch (err) { + expect((err as { code?: string }).code).toBe("raw_path"); + } + + const dense = `cwd=/${"a//".repeat(1300)}`; + try { + enforceEventStructureLimits({ detail: dense }); + throw new Error("expected raw_path rejection for a slash-dense chain"); + } catch (err) { + expect((err as { code?: string }).code).toBe("raw_path"); + } + + const notAPath = `https://example.com/${"a/".repeat(2000)}`; + expect(() => enforceEventStructureLimits({ detail: notAPath })).not.toThrow(); +}); + test("invalid JSON contract artifacts classify as artifact_mismatch", () => { const home = tempHome(); const artifactsDir = join(home, "artifacts");