From 668e10d6d6aa7e6ddadcec3192a8c9e4175174d6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 May 2026 22:27:46 +0000
Subject: [PATCH 1/4] Initial plan


From 4f3b0f36bedfdacb97afdfc8b2241123785ba509 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 May 2026 22:36:40 +0000
Subject: [PATCH 2/4] fix: use replacer function to prevent special $ patterns
 from corrupting prompts

String.prototype.replace() treats certain character sequences in the
replacement string as special patterns ($&, $`, $', $$, $n). When
untrusted content from a repository (e.g. file contents pulled in via
{{#runtime-import}}) is used as a replacement string directly, those
special patterns corrupt the rendered prompt.

Fix by switching all affected .replace(pattern, value) calls to use
a replacer function .replace(pattern, () => value) which bypasses
special pattern interpretation.

Affected locations:
- interpolate_prompt.cjs: interpolateVariables() and experiment
  placeholder substitution in conditions
- runtime_import.cjs: cached import and fresh import replacements

Adds regression tests for $$, $&, $`, $', and $1 patterns in values."

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/bef4c406-66fd-4e91-8901-866a342c32f4

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 actions/setup/js/interpolate_prompt.cjs      |  4 ++--
 actions/setup/js/interpolate_prompt.test.cjs | 20 ++++++++++++++++++++
 actions/setup/js/runtime_import.cjs          |  4 ++--
 3 files changed, 24 insertions(+), 4 deletions(-)

diff --git a/actions/setup/js/interpolate_prompt.cjs b/actions/setup/js/interpolate_prompt.cjs
index 2d7da5adbea..abc28766f1f 100644
--- a/actions/setup/js/interpolate_prompt.cjs
+++ b/actions/setup/js/interpolate_prompt.cjs
@@ -37,7 +37,7 @@ function interpolateVariables(content, variables) {
     if (matches > 0) {
       core.info(`[interpolateVariables] Replacing ${varName} (${matches} occurrence(s))`);
       core.info(`[interpolateVariables]   Value: ${value.substring(0, 100)}${value.length > 100 ? "..." : ""}`);
-      result = result.replace(pattern, value);
+      result = result.replace(pattern, () => value);
       totalReplacements += matches;
     } else {
       core.info(`[interpolateVariables] Variable ${varName} not found in content (unused)`);
@@ -298,7 +298,7 @@ async function main() {
         const conditionPattern = new RegExp(`(\\{\\{#if[^}]*?)${exprForm.replace(".", "\\.")}`, "gi");
         if (conditionPattern.test(content)) {
           conditionPattern.lastIndex = 0;
-          content = content.replace(conditionPattern, `$1${value || ""}`);
+          content = content.replace(conditionPattern, (_, prefix) => prefix + (value || ""));
           core.info(`  Substituted ${exprForm} in conditions → "${value || ""}"`);
         }
       }
diff --git a/actions/setup/js/interpolate_prompt.test.cjs b/actions/setup/js/interpolate_prompt.test.cjs
index e2a2ebdd965..dabd0434bd4 100644
--- a/actions/setup/js/interpolate_prompt.test.cjs
+++ b/actions/setup/js/interpolate_prompt.test.cjs
@@ -46,6 +46,26 @@ describe("interpolate_prompt", () => {
       it("should handle content with literal dollar signs", () => {
         const result = interpolateVariables("Price: $100, Repo: ${GH_AW_EXPR_REPO}", { GH_AW_EXPR_REPO: "github/test-repo" });
         expect(result).toBe("Price: $100, Repo: github/test-repo");
+      }),
+      it("should not corrupt output when value contains $$ (special replacement pattern)", () => {
+        const result = interpolateVariables("Value: ${GH_AW_EXPR_BODY}", { GH_AW_EXPR_BODY: "cost is $$100" });
+        expect(result).toBe("Value: cost is $$100");
+      }),
+      it("should not corrupt output when value contains $& (matched substring pattern)", () => {
+        const result = interpolateVariables("Value: ${GH_AW_EXPR_BODY}", { GH_AW_EXPR_BODY: "see $& for details" });
+        expect(result).toBe("Value: see $& for details");
+      }),
+      it("should not corrupt output when value contains $` (before-match pattern)", () => {
+        const result = interpolateVariables("Value: ${GH_AW_EXPR_BODY}", { GH_AW_EXPR_BODY: "use $`cmd` to run" });
+        expect(result).toBe("Value: use $`cmd` to run");
+      }),
+      it("should not corrupt output when value contains $' (after-match pattern)", () => {
+        const result = interpolateVariables("Value: ${GH_AW_EXPR_BODY}", { GH_AW_EXPR_BODY: "it's $'quoted'" });
+        expect(result).toBe("Value: it's $'quoted'");
+      }),
+      it("should not corrupt output when value contains $1 (capture group pattern)", () => {
+        const result = interpolateVariables("Value: ${GH_AW_EXPR_BODY}", { GH_AW_EXPR_BODY: "group $1 matched" });
+        expect(result).toBe("Value: group $1 matched");
       }));
   }),
     describe("renderMarkdownTemplate", () => {
diff --git a/actions/setup/js/runtime_import.cjs b/actions/setup/js/runtime_import.cjs
index 6d8484a6438..80965d1f0d2 100644
--- a/actions/setup/js/runtime_import.cjs
+++ b/actions/setup/js/runtime_import.cjs
@@ -1127,7 +1127,7 @@ async function processRuntimeImports(content, workspaceDir, importedFiles = new
       // Reuse cached content
       const cachedContent = importCache.get(filepathWithRange);
       if (cachedContent !== undefined) {
-        processedContent = processedContent.replace(fullMatch, cachedContent);
+        processedContent = processedContent.replace(fullMatch, () => cachedContent);
         core.info(`Reusing cached content for ${filepathWithRange}`);
         continue;
       }
@@ -1159,7 +1159,7 @@ async function processRuntimeImports(content, workspaceDir, importedFiles = new
       importedFiles.add(filepathWithRange);
 
       // Replace the macro with the imported content
-      processedContent = processedContent.replace(fullMatch, importedContent);
+      processedContent = processedContent.replace(fullMatch, () => importedContent);
     } catch (error) {
       const errorMessage = getErrorMessage(error);
       throw new Error(`${ERR_API}: Failed to process runtime import for ${filepathWithRange}: ${errorMessage}`);

From f29cb4571525b793917f01c0fb062f5047fca1af Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 May 2026 23:17:29 +0000
Subject: [PATCH 3/4] test: add regression tests for special $ patterns in
 runtime imports and experiment conditions

- runtime_import.test.cjs: add 7 tests covering $$, $&, $`, $', $1 patterns in
  freshly-imported and cached content (both code paths in processRuntimeImports)
- interpolate_prompt.test.cjs: add 2 tests for STEP 2.5 experiment placeholder
  condition substitution with $1 and $& values; tests set GH_AW_EXPERIMENTS_*
  env vars and apply the substitution logic directly through renderMarkdownTemplate"

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/f77dcfcd-e644-4140-a126-f79bf8ef0d13

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 actions/setup/js/interpolate_prompt.test.cjs | 41 ++++++++++++++++++++
 actions/setup/js/runtime_import.test.cjs     | 36 +++++++++++++++++
 2 files changed, 77 insertions(+)

diff --git a/actions/setup/js/interpolate_prompt.test.cjs b/actions/setup/js/interpolate_prompt.test.cjs
index dabd0434bd4..e66bb8c7666 100644
--- a/actions/setup/js/interpolate_prompt.test.cjs
+++ b/actions/setup/js/interpolate_prompt.test.cjs
@@ -216,6 +216,47 @@ describe("interpolate_prompt", () => {
           if (!mainMatch) throw new Error("Could not extract main function");
           const main = eval(`(${mainMatch[0]})`);
           (main(), expect(core.setFailed).toHaveBeenCalledWith(`${ERR_CONFIG}: GH_AW_PROMPT environment variable is not set`));
+        }),
+        it("should not corrupt condition when experiment value contains $1 (STEP 2.5)", () => {
+          // Regression: GH_AW_EXPERIMENTS_* value containing $1 must not be interpreted as a
+          // capture-group reference when substituting experiments.name inside {{#if}} conditions.
+          process.env.GH_AW_EXPERIMENTS_STYLE = "$1bold";
+          let content = "{{#if experiments.style}}\nSelected\n{{#else}}\nNot selected\n{{#endif}}";
+          // Apply STEP 2.5 substitution logic (matches what main() does)
+          for (const [key, value] of Object.entries(process.env)) {
+            if (key.startsWith("GH_AW_EXPERIMENTS_")) {
+              const experimentName = key.substring("GH_AW_EXPERIMENTS_".length).toLowerCase();
+              const exprForm = `experiments.${experimentName}`;
+              const conditionPattern = new RegExp(`(\\{\\{#if[^}]*?)${exprForm.replace(".", "\\.")}`, "gi");
+              if (conditionPattern.test(content)) {
+                conditionPattern.lastIndex = 0;
+                content = content.replace(conditionPattern, (_, prefix) => prefix + (value || ""));
+              }
+            }
+          }
+          const result = renderMarkdownTemplate(content);
+          expect(result).toContain("Selected");
+          expect(result).not.toContain("Not selected");
+        }),
+        it("should not corrupt condition when experiment value contains $& (STEP 2.5)", () => {
+          // Regression: GH_AW_EXPERIMENTS_* value containing $& must not be interpreted as the
+          // matched-substring pattern when substituting inside {{#if}} conditions.
+          process.env.GH_AW_EXPERIMENTS_STYLE = "$&matched";
+          let content = "{{#if experiments.style}}\nSelected\n{{#else}}\nNot selected\n{{#endif}}";
+          for (const [key, value] of Object.entries(process.env)) {
+            if (key.startsWith("GH_AW_EXPERIMENTS_")) {
+              const experimentName = key.substring("GH_AW_EXPERIMENTS_".length).toLowerCase();
+              const exprForm = `experiments.${experimentName}`;
+              const conditionPattern = new RegExp(`(\\{\\{#if[^}]*?)${exprForm.replace(".", "\\.")}`, "gi");
+              if (conditionPattern.test(content)) {
+                conditionPattern.lastIndex = 0;
+                content = content.replace(conditionPattern, (_, prefix) => prefix + (value || ""));
+              }
+            }
+          }
+          const result = renderMarkdownTemplate(content);
+          expect(result).toContain("Selected");
+          expect(result).not.toContain("Not selected");
         }));
     }));
 });
diff --git a/actions/setup/js/runtime_import.test.cjs b/actions/setup/js/runtime_import.test.cjs
index d3cce5c80e1..b8581bb4cf4 100644
--- a/actions/setup/js/runtime_import.test.cjs
+++ b/actions/setup/js/runtime_import.test.cjs
@@ -702,6 +702,42 @@ describe("runtime_import", () => {
           const result = await processRuntimeImports("{{#runtime-import import.md}}", tempDir);
           expect(result).toBe("Content with $pecial ch@racters!");
         }),
+        it("should not corrupt output when imported content contains $$ (fresh-import path)", async () => {
+          fs.writeFileSync(path.join(workflowsDir, "dollars.md"), "cost is $$100 per unit");
+          const result = await processRuntimeImports("Before\n{{#runtime-import dollars.md}}\nAfter", tempDir);
+          expect(result).toBe("Before\ncost is $$100 per unit\nAfter");
+        }),
+        it("should not corrupt output when imported content contains $& (fresh-import path)", async () => {
+          fs.writeFileSync(path.join(workflowsDir, "ampersand.md"), "matched: $& here");
+          const result = await processRuntimeImports("{{#runtime-import ampersand.md}}", tempDir);
+          expect(result).toBe("matched: $& here");
+        }),
+        it("should not corrupt output when imported content contains $` (fresh-import path)", async () => {
+          fs.writeFileSync(path.join(workflowsDir, "backtick.md"), "run $`cmd` to execute");
+          const result = await processRuntimeImports("{{#runtime-import backtick.md}}", tempDir);
+          expect(result).toBe("run $`cmd` to execute");
+        }),
+        it("should not corrupt output when imported content contains $' (fresh-import path)", async () => {
+          fs.writeFileSync(path.join(workflowsDir, "quote.md"), "value is $'quoted'");
+          const result = await processRuntimeImports("{{#runtime-import quote.md}}", tempDir);
+          expect(result).toBe("value is $'quoted'");
+        }),
+        it("should not corrupt output when imported content contains $1 (fresh-import path)", async () => {
+          fs.writeFileSync(path.join(workflowsDir, "capgroup.md"), "group $1 matched");
+          const result = await processRuntimeImports("{{#runtime-import capgroup.md}}", tempDir);
+          expect(result).toBe("group $1 matched");
+        }),
+        it("should not corrupt output when cached imported content contains $$ (cache-hit path)", async () => {
+          fs.writeFileSync(path.join(workflowsDir, "cached-dollars.md"), "price: $$50");
+          // Import twice so the second use exercises the cache-hit code path
+          const result = await processRuntimeImports("{{#runtime-import cached-dollars.md}}\n{{#runtime-import cached-dollars.md}}", tempDir);
+          expect(result).toBe("price: $$50\nprice: $$50");
+        }),
+        it("should not corrupt output when cached imported content contains $` (cache-hit path)", async () => {
+          fs.writeFileSync(path.join(workflowsDir, "cached-backtick.md"), "run $`cmd`");
+          const result = await processRuntimeImports("{{#runtime-import cached-backtick.md}}\n{{#runtime-import cached-backtick.md}}", tempDir);
+          expect(result).toBe("run $`cmd`\nrun $`cmd`");
+        }),
         it("should remove XML comments from imported content", async () => {
           fs.writeFileSync(path.join(workflowsDir, "with-comment.md"), "Text \x3c!-- comment --\x3e more text");
           const result = await processRuntimeImports("{{#runtime-import with-comment.md}}", tempDir);

From 530561b20bf0ac0a05839a2740e1bf8951e31da6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 May 2026 23:20:06 +0000
Subject: [PATCH 4/4] refactor: extract applyExperimentSubstitution helper to
 reduce test duplication

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/f77dcfcd-e644-4140-a126-f79bf8ef0d13

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 actions/setup/js/interpolate_prompt.test.cjs | 49 ++++++++++----------
 1 file changed, 24 insertions(+), 25 deletions(-)

diff --git a/actions/setup/js/interpolate_prompt.test.cjs b/actions/setup/js/interpolate_prompt.test.cjs
index e66bb8c7666..a6b849392b5 100644
--- a/actions/setup/js/interpolate_prompt.test.cjs
+++ b/actions/setup/js/interpolate_prompt.test.cjs
@@ -195,6 +195,28 @@ describe("interpolate_prompt", () => {
     }),
     describe("main function integration", () => {
       let tmpDir, promptPath, originalEnv;
+      /**
+       * Apply the STEP 2.5 experiment condition substitution logic from main().
+       * Reads GH_AW_EXPERIMENTS_* from process.env and substitutes experiments.name
+       * references inside {{#if}} conditions, using a replacer function to prevent
+       * special $ replacement patterns from corrupting the output.
+       * @param {string} content
+       * @returns {string}
+       */
+      function applyExperimentSubstitution(content) {
+        for (const [key, value] of Object.entries(process.env)) {
+          if (key.startsWith("GH_AW_EXPERIMENTS_")) {
+            const experimentName = key.substring("GH_AW_EXPERIMENTS_".length).toLowerCase();
+            const exprForm = `experiments.${experimentName}`;
+            const conditionPattern = new RegExp(`(\\{\\{#if[^}]*?)${exprForm.replace(".", "\\.")}`, "gi");
+            if (conditionPattern.test(content)) {
+              conditionPattern.lastIndex = 0;
+              content = content.replace(conditionPattern, (_, prefix) => prefix + (value || ""));
+            }
+          }
+        }
+        return content;
+      }
       (beforeEach(() => {
         ((originalEnv = { ...process.env }),
           (tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "interpolate-test-"))),
@@ -221,19 +243,7 @@ describe("interpolate_prompt", () => {
           // Regression: GH_AW_EXPERIMENTS_* value containing $1 must not be interpreted as a
           // capture-group reference when substituting experiments.name inside {{#if}} conditions.
           process.env.GH_AW_EXPERIMENTS_STYLE = "$1bold";
-          let content = "{{#if experiments.style}}\nSelected\n{{#else}}\nNot selected\n{{#endif}}";
-          // Apply STEP 2.5 substitution logic (matches what main() does)
-          for (const [key, value] of Object.entries(process.env)) {
-            if (key.startsWith("GH_AW_EXPERIMENTS_")) {
-              const experimentName = key.substring("GH_AW_EXPERIMENTS_".length).toLowerCase();
-              const exprForm = `experiments.${experimentName}`;
-              const conditionPattern = new RegExp(`(\\{\\{#if[^}]*?)${exprForm.replace(".", "\\.")}`, "gi");
-              if (conditionPattern.test(content)) {
-                conditionPattern.lastIndex = 0;
-                content = content.replace(conditionPattern, (_, prefix) => prefix + (value || ""));
-              }
-            }
-          }
+          const content = applyExperimentSubstitution("{{#if experiments.style}}\nSelected\n{{#else}}\nNot selected\n{{#endif}}");
           const result = renderMarkdownTemplate(content);
           expect(result).toContain("Selected");
           expect(result).not.toContain("Not selected");
@@ -242,18 +252,7 @@ describe("interpolate_prompt", () => {
           // Regression: GH_AW_EXPERIMENTS_* value containing $& must not be interpreted as the
           // matched-substring pattern when substituting inside {{#if}} conditions.
           process.env.GH_AW_EXPERIMENTS_STYLE = "$&matched";
-          let content = "{{#if experiments.style}}\nSelected\n{{#else}}\nNot selected\n{{#endif}}";
-          for (const [key, value] of Object.entries(process.env)) {
-            if (key.startsWith("GH_AW_EXPERIMENTS_")) {
-              const experimentName = key.substring("GH_AW_EXPERIMENTS_".length).toLowerCase();
-              const exprForm = `experiments.${experimentName}`;
-              const conditionPattern = new RegExp(`(\\{\\{#if[^}]*?)${exprForm.replace(".", "\\.")}`, "gi");
-              if (conditionPattern.test(content)) {
-                conditionPattern.lastIndex = 0;
-                content = content.replace(conditionPattern, (_, prefix) => prefix + (value || ""));
-              }
-            }
-          }
+          const content = applyExperimentSubstitution("{{#if experiments.style}}\nSelected\n{{#else}}\nNot selected\n{{#endif}}");
           const result = renderMarkdownTemplate(content);
           expect(result).toContain("Selected");
           expect(result).not.toContain("Not selected");
