fix(story-mode): no input shape blocks the server: linear tag and commit scans

Every string Story Mode matches is agent-written (and an agent can be
prompt-injected), and one regex call blocks the single-threaded server.
Timing each helper at its own input cap with crafted shapes found five
that were quadratic or worse:

- A lazy `<tag>[\s\S]*?</tag>` rescans to the end for every opener that
  never closes: system reminders, teammate messages and task-notification
  fields took 1.5 to 29 s on 0.5 to 1 MB of prompt text. They are found
  with indexOf now (withoutBlocks, blocks, blockText1).
- The commit message regex backtracked over many `git commit` tokens
  (290 ms at 4,000 characters, growing x8.7 per doubling) and ran on the
  whole command. It is a bounded line-by-line scan with the same rules.
- The "This command modified …" note was searched with a lazy match per
  repeat of the phrase (7 s on 1 MB of output). It is one anchored try
  at its last occurrence, where Claude Code appends it.
- firstSentence and planFromText now read only the start they need.

The worst shape is now under 10 ms at 1 MB. A test times five of them at
sizes where the old code took a second or more.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 14:11:01 +02:00
1 parent 67595ea980
commit 21de40309d
2 files changed
+128 -20

No files matched your search

+25
View File
@@ -971,6 +971,31 @@ describe('buildStoryPage', () => {
expect(pageOf([prompt('c'), ...bash('b1', 'git commit -qm x', 'Commit', ''), end(1000)]).events[1].commits).toBe(1);
});
it('crafted agent text never takes seconds: every helper stays linear on its worst shapes', () => {
// Each shape took one to several seconds before (a lazy regex rescanning per opener, or a
// backtracking one over a long one-liner); a single call blocks the whole server.
const rep = (str: string, n: number) => str.repeat(Math.ceil(n / str.length)).slice(0, n);
const ms = (fn: () => unknown) => {
const start = performance.now();
fn();
return performance.now() - start;
};
const prompt1 = (content: string) => () => buildStoryTurns(entriesOf([prompt(content), said('ok'), end(1000)]));
const bashOut = (command: string, output: string) => () =>
pageOf([prompt('go'), ...bash('b1', command, 'x', output), end(1000)]);
const shapes: Array<[string, () => unknown]> = [
['teammate openers', prompt1(rep('<teammate-message teammate_id="a">x ', 400_000))],
['system-reminder openers', prompt1('hi ' + rep('<system-reminder>x ', 400_000))],
['task-notification field openers', prompt1('<task-notification>\n' + rep('<summary>x ', 400_000))],
['git commit tokens', bashOut(rep('git commit ', 8000), 'ok')],
[
'modified-note phrases',
bashOut('python3 x.py', rep("This command modified 1 file you've previously read: a", 400_000)),
],
];
for (const [name, fn] of shapes) expect([name, ms(fn) < 250]).toEqual([name, true]);
});
it('a python or node script fed by a here-document: the files it opens for writing, never guessed', () => {
const files = (command: string) => heredocWrites(command, '/w').map((d) => d.file);
expect(