Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion runtime/typescript/packages/core/src/parsers/prompty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,18 @@ import {
import type { Parser } from "../core/interfaces.js";

// Role boundary regex — matches lines like `system:` or `user[name="Alice"]:`
//
// ReDoS hardening: the previous body `((\w+\s*=\s*"?[^"]*"?\s*,?\s*)+)` nested a
// repetition whose value class overlapped the `\w+=` iteration starter and the
// separators, so a long attribute run (e.g. `name=aname=a...`) could be partitioned
// exponentially — catastrophic backtracking. JavaScript has no possessive quantifiers
// (unlike the Python fix in #457), so instead we remove the nested `+`: require an
// attribute-like start (`key=`) and then swallow the remainder of the bracket with a
// single `[^\]]*` class that cannot overlap the closing `]`. This is structurally
// linear. Individual attributes are still split later by ATTR_RE. See #446.
const ROLE_NAMES = [...ROLES].filter((r) => r !== "tool").sort().join("|");
const BOUNDARY_RE = new RegExp(
`^\\s*#?\\s*(${ROLE_NAMES})(\\[((\\w+\\s*=\\s*"?[^"]*"?\\s*,?\\s*)+)\\])?\\s*:\\s*$`,
`^\\s*#?\\s*(${ROLE_NAMES})(\\[\\w+\\s*=\\s*[^\\]]*\\])?\\s*:\\s*$`,
"i",
);

Expand Down
23 changes: 23 additions & 0 deletions runtime/typescript/packages/core/tests/parsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,29 @@ Hello!`;
expect(messages[0].metadata.name).toBe("Alice");
});

it("parses multiple unquoted attributes", async () => {
const rendered = `user[name=Bob, id=7]:
Hi`;

const messages = await parser.parse(agent, rendered);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe("user");
expect(messages[0].metadata.name).toBe("Bob");
expect(messages[0].metadata.id).toBe(7);
});

it("is not vulnerable to ReDoS on adversarial role attributes (#446)", async () => {
// Unterminated-quote / unbounded attribute run that triggered catastrophic
// backtracking in the old `"?[^"]*"?` value class. Must complete near-instantly.
const evil = `user[${"name=a".repeat(24)}"`;
const start = performance.now();
const messages = await parser.parse(agent, `${evil}:\nHi`);
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(1000);
// The adversarial line is not a valid boundary, so it stays as content.
expect(messages).toHaveLength(1);
});

it("preserves inline markdown images as text", async () => {
const rendered = `user:
Look at this ![photo](https://example.com/img.png)`;
Expand Down
Loading