diff --git a/eval/deepswe/run_shard.py b/eval/deepswe/run_shard.py index 5249e5e00..3362adc11 100644 --- a/eval/deepswe/run_shard.py +++ b/eval/deepswe/run_shard.py @@ -149,14 +149,17 @@ def write_token_file(): """Publish the current host token where the proxy can re-read it. Atomic: the proxy stats this file on every request, so a partial write would - be served as a bearer. Written 0600 because it is a live credential. + be served as a bearer. Created 0600 because it is a live credential. """ tok = oauth_token(min_valid_s=1800) if tok is None: return False tmp = TOKEN_FILE.with_suffix(".tmp") - tmp.write_text(tok) - tmp.chmod(0o600) + # 0600 at creation: chmod after writing leaves the token briefly readable + # by any user on the host. + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as fh: + fh.write(tok) os.replace(tmp, TOKEN_FILE) return True diff --git a/src/core/render.ts b/src/core/render.ts index 5c40ed729..924a0ead9 100644 --- a/src/core/render.ts +++ b/src/core/render.ts @@ -342,11 +342,25 @@ const TAB_WIDTH = 4; // standard 4-space tab stops (logs, code, tool output are export function minifyForRender(text: string): string { return text .split('\n') - .map((line) => line.replace(/[ \t]+$/, '')) + .map(trimLineEnd) .join('\n') .replace(/\n{4,}/g, '\n\n\n'); // 4+ \n → 3 \n (max 2 blank lines) } +/** Drop trailing spaces and tabs from one line. + * Scanned backwards: /[ \t]+$/ retries from every position in a long run of + * spaces when the line does not end in one, which is quadratic on one very + * long line. */ +function trimLineEnd(line: string): string { + let end = line.length; + while (end > 0) { + const c = line.charCodeAt(end - 1); + if (c !== 32 && c !== 9) break; + end -= 1; + } + return end === line.length ? line : line.slice(0, end); +} + // --- R3 reflow ------------------------------------------------------------- // // Marks each original hard newline with U+21B5 ↵ so the model can distinguish diff --git a/src/core/transform.ts b/src/core/transform.ts index 5ec69c95f..efffe5635 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -816,6 +816,38 @@ const KNOWN_STATIC_TAGS = [ 'toolUseInstructions', ] as const; +/** Tag-name and whitespace classifiers matching /[a-zA-Z]/, + * /[a-zA-Z0-9_-]/ and /\s/. */ +function isTagNameStart(c: number): boolean { + return (c >= 97 && c <= 122) || (c >= 65 && c <= 90); // a-z A-Z +} + +function isTagNameChar(c: number): boolean { + return ( + (c >= 97 && c <= 122) || // a-z + (c >= 65 && c <= 90) || // A-Z + (c >= 48 && c <= 57) || // 0-9 + c === 95 || // _ + c === 45 // - + ); +} + +function isTagSpace(c: number): boolean { + if (c === 32 || (c >= 9 && c <= 13)) return true; // space, \t \n \v \f \r + if (c < 0xa0) return false; + return ( + c === 0xa0 || + c === 0x1680 || + (c >= 0x2000 && c <= 0x200a) || + c === 0x2028 || + c === 0x2029 || + c === 0x202f || + c === 0x205f || + c === 0x3000 || + c === 0xfeff + ); +} + function splitStaticDynamic(text: string): { staticText: string; dynamicText: string; @@ -852,16 +884,55 @@ function splitStaticDynamic(text: string): { // surfacing the tag name lets us detect it within hours of a release. const known = new Set(DYNAMIC_BLOCK_TAGS); const knownStatic = new Set(KNOWN_STATIC_TAGS); - const sniffer = /<([a-zA-Z][a-zA-Z0-9_-]*)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/g; const unknown = new Set(); const staticTagContents = new Map(); - let s: RegExpExecArray | null; - while ((s = sniffer.exec(staticBuf)) !== null) { - const tag = s[1]!; - if (tag.length > 64) continue; - if (!known.has(tag) && !knownStatic.has(tag)) unknown.add(tag); - // Fold repeated tags (e.g. several s) into one fingerprint. - staticTagContents.set(tag, (staticTagContents.get(tag) ?? '') + s[2]!); + // Scanned by index: matching this with a regex costs quadratic time on + // untrusted text, since both a lazy `[\s\S]*?` body and an `(?:\s[^>]*)?>` + // attribute run rescan the tail once per candidate tag. + const noCloser = new Set(); + let i = 0; + while (i < staticBuf.length) { + const lt = staticBuf.indexOf('<', i); + if (lt < 0) break; + let j = lt + 1; + if (!isTagNameStart(staticBuf.charCodeAt(j))) { + i = lt + 1; + continue; + } + j += 1; + while (j < staticBuf.length && isTagNameChar(staticBuf.charCodeAt(j))) j += 1; + // Either `` or ``; anything else is not an opening tag. + let gt: number; + if (staticBuf[j] === '>') { + gt = j; + } else if (j < staticBuf.length && isTagSpace(staticBuf.charCodeAt(j))) { + gt = staticBuf.indexOf('>', j); + // No `>` left, so no later opening can complete either. + if (gt < 0) break; + } else { + i = lt + 1; + continue; + } + const tag = staticBuf.slice(lt + 1, j); + const contentStart = gt + 1; + const closer = ``; + // A closer missing after one opening is missing for every later opening of + // the same tag, so record it and skip the repeated failed scan. + const end = noCloser.has(tag) ? -1 : staticBuf.indexOf(closer, contentStart); + if (end < 0) { + noCloser.add(tag); + i = lt + 1; + continue; + } + if (tag.length <= 64) { + if (!known.has(tag) && !knownStatic.has(tag)) unknown.add(tag); + // Fold repeated tags (e.g. several s) into one fingerprint. + staticTagContents.set( + tag, + (staticTagContents.get(tag) ?? '') + staticBuf.slice(contentStart, end), + ); + } + i = end + closer.length; } return { @@ -1114,14 +1185,29 @@ export function firstMessageHasSystemReminder(messages: Message[] | undefined): return false; } +/** Body of the first `` pair, or undefined when unpaired. + * Scanned by index: a lazy `[\s\S]*?` span rescans the tail for every + * candidate opening, so many openings and no closer costs quadratic time. + * Tag names are ASCII literals, so lowercasing suffices for a + * case-insensitive match. */ +function firstTagBody(text: string, tag: string): string | undefined { + const open = `<${tag}>`; + const close = ``; + const haystack = text.toLowerCase(); + const start = haystack.indexOf(open); + if (start < 0) return undefined; + const end = haystack.indexOf(close, start + open.length); + if (end < 0) return undefined; + return text.slice(start + open.length, end); +} + /** Parse structured fields from the dynamic slab for telemetry. Read-only. */ export function extractEnvFields(dynamicText: string): EnvFields { const out: EnvFields = {}; if (!dynamicText) return out; - const envMatch = /([\s\S]*?)<\/env>/i.exec(dynamicText); - if (envMatch) { - const body = envMatch[1]!; + const body = firstTagBody(dynamicText, 'env'); + if (body !== undefined) { const cwd = /(?:^|\n)\s*Working directory:\s*(.+?)\s*(?:\n|$)/i.exec(body); if (cwd) out.cwd = cwd[1]!.trim(); const gitRepo = /(?:^|\n)\s*Is directory a git repo:\s*(Yes|No)\b/i.exec(body); diff --git a/tests/redos-guard.test.ts b/tests/redos-guard.test.ts new file mode 100644 index 000000000..4bf159e6e --- /dev/null +++ b/tests/redos-guard.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import { extractEnvFields, transformRequest } from '../src/core/transform.js'; +import { minifyForRender } from '../src/core/render.js'; + +// Characterization + ReDoS regression tests for the two regex sites reported by +// code scanning (js/polynomial-redos): the span in extractEnvFields and +// the static-tag sniffer in splitStaticDynamic. Both parse untrusted request +// text, so runtime has to stay near-linear in input size while the parsed +// results stay unchanged. + +// Generous wall-clock budget so the suite stays stable on loaded hosts. The +// pre-fix code needed over 20s on the 640KB case, so the signal is not marginal. +const BUDGET_MS = 2000; + +function elapsed(fn: () => void): number { + const t0 = performance.now(); + fn(); + return performance.now() - t0; +} + +const WELL_FORMED_ENV = [ + '', + 'Working directory: /tmp/proj', + 'Is directory a git repo: Yes', + 'Platform: darwin', + 'OS Version: Darwin 24.0.0', + "Today's date: 2026-05-18", + '', +].join('\n'); + +describe('extractEnvFields behaviour is preserved', () => { + it('extracts every field from a well-formed block', () => { + const out = extractEnvFields(WELL_FORMED_ENV); + expect(out.cwd).toBe('/tmp/proj'); + expect(out.isGitRepo).toBe(true); + expect(out.platform).toBe('darwin'); + expect(out.osVersion).toBe('Darwin 24.0.0'); + expect(out.today).toBe('2026-05-18'); + }); + + it('reads a No git-repo line as false', () => { + const out = extractEnvFields(WELL_FORMED_ENV.replace('git repo: Yes', 'git repo: No')); + expect(out.isGitRepo).toBe(false); + }); + + it('returns an empty object for empty input', () => { + expect(extractEnvFields('')).toEqual({}); + }); + + it('reads no env fields when the closing tag is missing', () => { + const out = extractEnvFields('\nWorking directory: /tmp/proj\n'); + expect(out.cwd).toBeUndefined(); + }); + + it('picks up branch outside ', () => { + expect(extractEnvFields('On branch feature/x').gitBranch).toBe('feature/x'); + expect(extractEnvFields('Branch: main').gitBranch).toBe('main'); + expect(extractEnvFields('Current branch: dev').gitBranch).toBe('dev'); + }); + + it('keeps the first block when several are present', () => { + const two = '\nPlatform: darwin\n\n\nPlatform: linux\n'; + expect(extractEnvFields(two).platform).toBe('darwin'); + }); + + it('matches the env tag case-insensitively', () => { + expect(extractEnvFields('\nPlatform: win32\n').platform).toBe('win32'); + }); + + it('keeps fields that sit on the first line of the block', () => { + expect(extractEnvFields('Platform: darwin\n').platform).toBe('darwin'); + }); +}); + +describe('extractEnvFields stays fast on hostile input', () => { + // Worst case for an unbounded lazy span: the opening tag is present and the + // closing tag never arrives, so the engine must scan the whole tail. + it('handles a large unterminated block within budget', () => { + const hostile = '' + 'a'.repeat(640_000); + expect(elapsed(() => extractEnvFields(hostile))).toBeLessThan(BUDGET_MS); + }); + + it('handles many unterminated openings within budget', () => { + const hostile = ''.repeat(50_000); + expect(elapsed(() => extractEnvFields(hostile))).toBeLessThan(BUDGET_MS); + }); + + it('handles near-miss field lines within budget', () => { + const hostile = '' + '\n' + ' '.repeat(320_000) + 'x' + ''; + expect(elapsed(() => extractEnvFields(hostile))).toBeLessThan(BUDGET_MS); + }); + + it('handles near-miss branch lines within budget', () => { + const hostile = '\n' + ' '.repeat(320_000) + 'x'; + expect(elapsed(() => extractEnvFields(hostile))).toBeLessThan(BUDGET_MS); + }); + + it('scales sub-quadratically with input size', () => { + const small = elapsed(() => extractEnvFields('' + 'a'.repeat(80_000))); + const large = elapsed(() => extractEnvFields('' + 'a'.repeat(640_000))); + // 8x the input. Quadratic would be roughly 64x; the slack absorbs timing + // noise while still failing loudly on a genuine blowup. + expect(large).toBeLessThan(Math.max(small, 1) * 24); + }); +}); + +describe('static tag sniffer behaviour is preserved', () => { + async function unknownTagsFor(extra: string): Promise { + // Dense slab keeps the compression gate green so the sniffer runs. + const slab = 'claude.md ground truth. '.repeat(2200); + const body = new TextEncoder().encode( + JSON.stringify({ + model: 'claude', + messages: [{ role: 'user', content: 'hi' }], + system: slab + '\n' + extra, + }), + ); + const { info } = await transformRequest(body); + return info.unknownStaticTags ?? []; + } + + it('surfaces an unknown tag-shaped block in the static slab', async () => { + expect(await unknownTagsFor('whatever')).toContain('brandNewTag'); + }); + + it('does not report known static tags', async () => { + expect(await unknownTagsFor('some types')).not.toContain('types'); + }); + + it('does not report a tag whose closing tag is absent', async () => { + expect(await unknownTagsFor('no closer here')).not.toContain('danglingTag'); + }); + + it('reports a tag carrying attributes', async () => { + expect(await unknownTagsFor('body')).toContain('withAttrs'); + }); + + it('reports several distinct unknown tags', async () => { + const tags = await unknownTagsFor('a\nb'); + expect(tags).toContain('alphaTag'); + expect(tags).toContain('betaTag'); + }); + + it('stays within budget on unterminated tag openings', async () => { + // ''. + // Quadratic against a regex opening scan, linear against an index scan. + // A regex scan needs ~18s here; an index scan needs well under a + // millisecond, so the budget separates the two by a wide margin. + const hostile = ' { + // \u00a0 satisfies /\s/, so the previous regex accepted it after the name. + expect(await unknownTagsFor('body')).toContain( + 'oddSpaceTag', + ); + }); + + it('stays within budget on a hostile static slab', async () => { + // Many unclosed tag openings: the pathological shape for the sniffer. + const hostile = ''.repeat(20_000); + const t0 = performance.now(); + await unknownTagsFor(hostile); + expect(performance.now() - t0).toBeLessThan(10_000); + }); +}); + +describe('minifyForRender stays fast on hostile input', () => { + // A single very long line of spaces that does not end in one: the trailing + // whitespace strip has to reject at every position in the run. + it('handles one huge run of trailing spaces within budget', () => { + const hostile = ' '.repeat(400_000) + 'x'; + expect(elapsed(() => minifyForRender(hostile))).toBeLessThan(BUDGET_MS); + }); + + it('handles a huge run of tabs within budget', () => { + const hostile = '\t'.repeat(400_000) + 'x'; + expect(elapsed(() => minifyForRender(hostile))).toBeLessThan(BUDGET_MS); + }); + + it('scales sub-quadratically with line length', () => { + const small = elapsed(() => minifyForRender(' '.repeat(50_000) + 'x')); + const large = elapsed(() => minifyForRender(' '.repeat(400_000) + 'x')); + expect(large).toBeLessThan(Math.max(small, 1) * 24); + }); + + it('still strips trailing whitespace and collapses blank runs', () => { + expect(minifyForRender('a \nb\t\nc')).toBe('a\nb\nc'); + expect(minifyForRender('a\n\n\n\n\n\nb')).toBe('a\n\n\nb'); + expect(minifyForRender(' indent kept ')).toBe(' indent kept'); + expect(minifyForRender('mid line spaces')).toBe('mid line spaces'); + }); +});