Skip to content

Bug: String.prototype.replace() special replacement patterns corrupt rendered prompts #30449

@davidslater

Description

@davidslater

Bug: String.prototype.replace() special replacement patterns corrupt rendered prompts

Context

This is related to #29283

Problem

The JavaScript prompt rendering pipeline in gh-aw uses String.prototype.replace() to interpolate runtime content (from {{#runtime-import}} blocks and ${{ github.event.* }} expressions) into prompt templates. However, JavaScript's replace() method treats certain character sequences in the replacement string as special patterns:

Pattern Behavior
$$ Inserts a literal $
$& Inserts the matched substring
$` Inserts the portion of the string before the match
$' Inserts the portion of the string after the match
$n Inserts the nth capture group

When untrusted content from the target repository (e.g., file contents pulled in via {{#runtime-import}}) contains $`, the replacement engine splices everything before the match point — including the <system> block — into the rendered output. This results in:

  1. Duplicated <system> blocks appearing in the middle of user-content sections
  2. Corrupted prompt structure that can confuse model instruction hierarchy
  3. False positive threat detections in gh-aw-threat-detection because the rendered prompt.txt now contains multiple <system> tags

Reproduction

Given a prompt template:

<system>
You are a helpful assistant.
</system>

Here is the file content:
{{#runtime-import src="README.md"}}

And a README.md containing:

Use regex like `^\s*<system>` to match system blocks.

The rendered output incorrectly becomes:

<system>
You are a helpful assistant.
</system>

Here is the file content:
<system>
You are a helpful assistant.
</system>

Here is the file content:
Use regex like `^\s*<system>` to match system blocks.

The $` in the backtick-quoted regex caused everything before the match to be duplicated.

Current State

  • runtime_import.cjs / interpolate_prompt.cjs (or equivalent) uses str.replace(placeholder, content) where content is untrusted file content from the repo being analyzed.
  • No escaping of special $ sequences in the replacement string.
  • The downstream gh-aw-threat-detection tool has to use fragile heuristics (looksLikeReplacementTokenExpansion) to distinguish this rendering bug from actual prompt injection attacks.

Target State

  • All interpolation calls must neutralize special replacement patterns in the content string before passing it to replace().
  • The rendered prompt.txt must be a faithful representation of template + interpolated content with no structural corruption.

Approach

Option A: Use a replacer function (Preferred)

Replace:

rendered = template.replace(placeholder, content);

With:

rendered = template.replace(placeholder, () => content);

When a function is passed as the second argument to replace(), no special pattern substitution occurs — the return value is used as-is.

Option B: Escape $ characters in replacement strings

function escapeReplacement(str) {
  return str.replace(/\$/g, '$$$$');
}
rendered = template.replace(placeholder, escapeReplacement(content));

This doubles every $ so that $$ → literal $, neutralizing all special patterns.

Recommendation

Option A is simpler, more robust, and has no risk of double-escaping. Apply it to every .replace() call where the replacement string contains untrusted (runtime-imported or user-provided) content.

Testing

  1. Unit test: Create a prompt template with a placeholder, provide content containing each special pattern ($`, $&, $', $1), and assert the rendered output matches the literal content.
  2. Integration test: Use a real .github/prompts/ template that {{#runtime-import}}s a file containing $` and verify no duplication occurs.
  3. Regression test: Verify the existing test suite still passes (no behavioral change for content without $ patterns).

Impact

  • Eliminates a class of false-positive prompt injection detections in gh-aw-threat-detection
  • Prevents prompt structure corruption that could affect model behavior
  • Removes the need for the looksLikeReplacementTokenExpansion heuristic downstream

Metadata

Metadata

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions