From 8a86a9419b2fc9fac95ee2917c2714adecb484a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:56:40 +0000 Subject: [PATCH 1/3] Add @filepath expansion in interpolate_prompt: detect @/abs/path and expand with file content Agent-Logs-Url: https://github.com/github/gh-aw/sessions/f211b60f-12d4-400c-95e1-1eda2e8f9b86 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/interpolate_prompt.cjs | 24 +++++- actions/setup/js/runtime_import.cjs | 70 ++++++++++++++++ actions/setup/js/runtime_import.test.cjs | 102 +++++++++++++++++++++++ 3 files changed, 195 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/interpolate_prompt.cjs b/actions/setup/js/interpolate_prompt.cjs index 2097febc8a3..9d72644610b 100644 --- a/actions/setup/js/interpolate_prompt.cjs +++ b/actions/setup/js/interpolate_prompt.cjs @@ -7,7 +7,7 @@ const fs = require("fs"); const { isTruthy } = require("./is_truthy.cjs"); -const { processRuntimeImports } = require("./runtime_import.cjs"); +const { processRuntimeImports, expandFileReferences } = require("./runtime_import.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_API, ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs"); @@ -207,6 +207,28 @@ async function main() { core.info("No runtime import macros found, skipping runtime import processing"); } + // Step 1.5: Expand @/absolute/path file references + core.info("\n========================================"); + core.info("[main] STEP 1.5: @filepath Expansion"); + core.info("========================================"); + const hasFileReferences = /@\//.test(content); + if (hasFileReferences) { + const fileRefMatches = content.match(/@\/[^\s@]+/g) || []; + core.info(`Found ${fileRefMatches.length} potential @filepath reference(s)`); + fileRefMatches.forEach((ref, i) => { + core.info(` Reference ${i + 1}: ${ref.substring(0, 80)}${ref.length > 80 ? "..." : ""}`); + }); + + const beforeExpansion = content.length; + content = expandFileReferences(content, workspaceDir); + const afterExpansion = content.length; + + core.info(`@filepath expansion complete`); + core.info(`Content length change: ${beforeExpansion} -> ${afterExpansion} (${afterExpansion > beforeExpansion ? "+" : ""}${afterExpansion - beforeExpansion})`); + } else { + core.info("No @filepath references found, skipping expansion"); + } + // Step 2: Interpolate variables core.info("\n========================================"); core.info("[main] STEP 2: Variable Interpolation"); diff --git a/actions/setup/js/runtime_import.cjs b/actions/setup/js/runtime_import.cjs index bd62c7d0931..d95fce4d90b 100644 --- a/actions/setup/js/runtime_import.cjs +++ b/actions/setup/js/runtime_import.cjs @@ -1043,6 +1043,74 @@ async function processRuntimeImports(content, workspaceDir, importedFiles = new return processedContent; } +/** + * Checks if a file path is allowed for @filepath expansion. + * Allowed paths must be absolute, clean (no .. or . traversal), and within + * the workspace directory or /tmp/gh-aw. + * @param {string} filePath - The absolute file path to validate + * @param {string} workspaceDir - The GITHUB_WORKSPACE directory + * @returns {boolean} - True if the path is allowed + */ +function isAllowedFileReference(filePath, workspaceDir) { + // Must be absolute path + if (!path.isAbsolute(filePath)) return false; + + // Must be clean path (no .. or . directory traversal components) + const parts = filePath.split("/"); + for (const part of parts) { + if (part === ".." || part === ".") return false; + } + + const normalizedPath = path.normalize(filePath); + + // Check if within workspace directory + const normalizedWorkspace = path.normalize(workspaceDir); + const rel = path.relative(normalizedWorkspace, normalizedPath); + if (!rel.startsWith("..") && !path.isAbsolute(rel)) return true; + + // Check if within /tmp/gh-aw + if (normalizedPath === "/tmp/gh-aw" || normalizedPath.startsWith("/tmp/gh-aw/")) return true; + + return false; +} + +/** + * Expands @/absolute/path file references in content by replacing them with file contents. + * Paths must be absolute, clean (no .. or . traversal), and within the workspace directory + * or /tmp/gh-aw. Invalid paths, paths outside allowed directories, and missing files are + * silently ignored (the reference is left as-is with a warning). + * @param {string} content - The content with potential @/path references + * @param {string} workspaceDir - The GITHUB_WORKSPACE directory path + * @returns {string} - Content with valid file references expanded + */ +function expandFileReferences(content, workspaceDir) { + // Pattern: @ followed by an absolute path (starts with /) + // Distinguishes @/path from @username mentions (which don't start with /) + const pattern = /@(\/[^\s@]+)/g; + + return content.replace(pattern, (match, filePath) => { + if (!isAllowedFileReference(filePath, workspaceDir)) { + core.info(`[expandFileReferences] Ignoring disallowed path reference: ${filePath}`); + return match; + } + + if (!fs.existsSync(filePath)) { + core.warning(`[expandFileReferences] File not found: ${filePath}`); + return match; + } + + try { + const fileContent = fs.readFileSync(filePath, "utf8"); + core.info(`[expandFileReferences] Expanded file reference: ${filePath} (${fileContent.length} chars)`); + return fileContent; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.warning(`[expandFileReferences] Failed to read file ${filePath}: ${errorMessage}`); + return match; + } + }); +} + module.exports = { processRuntimeImports, processRuntimeImport, @@ -1055,4 +1123,6 @@ module.exports = { wrapExpressionsInTemplateConditionals, extractAndReplacePlaceholders, generatePlaceholderName, + isAllowedFileReference, + expandFileReferences, }; diff --git a/actions/setup/js/runtime_import.test.cjs b/actions/setup/js/runtime_import.test.cjs index 1a9c78d733a..b564f628f74 100644 --- a/actions/setup/js/runtime_import.test.cjs +++ b/actions/setup/js/runtime_import.test.cjs @@ -15,6 +15,8 @@ const { wrapExpressionsInTemplateConditionals, extractAndReplacePlaceholders, generatePlaceholderName, + isAllowedFileReference, + expandFileReferences, } = require("./runtime_import.cjs"); describe("runtime_import", () => { let tempDir; @@ -1698,4 +1700,104 @@ describe("runtime_import", () => { expect(result).not.toContain("__GH_AW_"); }); }); + + describe("isAllowedFileReference", () => { + let wsDir; + beforeEach(() => { + wsDir = fs.mkdtempSync(path.join(os.tmpdir(), "isallowed-test-")); + }); + afterEach(() => { + fs.rmSync(wsDir, { recursive: true, force: true }); + }); + + it("should allow absolute path within workspace", () => { + expect(isAllowedFileReference(path.join(wsDir, "file.txt"), wsDir)).toBe(true); + }); + it("should allow absolute path in subdirectory of workspace", () => { + expect(isAllowedFileReference(path.join(wsDir, "sub", "file.txt"), wsDir)).toBe(true); + }); + it("should allow /tmp/gh-aw/ paths", () => { + expect(isAllowedFileReference("/tmp/gh-aw/agent/comment-body.md", wsDir)).toBe(true); + }); + it("should allow /tmp/gh-aw root path", () => { + expect(isAllowedFileReference("/tmp/gh-aw", wsDir)).toBe(true); + }); + it("should reject relative paths", () => { + expect(isAllowedFileReference("relative/path.txt", wsDir)).toBe(false); + }); + it("should reject paths with .. traversal", () => { + expect(isAllowedFileReference("/tmp/gh-aw/../etc/passwd", wsDir)).toBe(false); + }); + it("should reject paths with . component", () => { + expect(isAllowedFileReference("/tmp/gh-aw/./file.txt", wsDir)).toBe(false); + }); + it("should reject /etc/passwd", () => { + expect(isAllowedFileReference("/etc/passwd", wsDir)).toBe(false); + }); + it("should reject /tmp/ paths that are not /tmp/gh-aw", () => { + expect(isAllowedFileReference("/tmp/other/file.txt", wsDir)).toBe(false); + }); + it("should reject /tmp/gh-aw-evil path (not a prefix match confusion)", () => { + expect(isAllowedFileReference("/tmp/gh-aw-evil/file.txt", wsDir)).toBe(false); + }); + }); + + describe("expandFileReferences", () => { + let wsDir; + let ghAwDir; + beforeEach(() => { + wsDir = fs.mkdtempSync(path.join(os.tmpdir(), "expand-fileref-test-")); + ghAwDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-test-")); + vi.clearAllMocks(); + }); + afterEach(() => { + fs.rmSync(wsDir, { recursive: true, force: true }); + fs.rmSync(ghAwDir, { recursive: true, force: true }); + }); + + it("should expand a valid @/path reference within workspace", () => { + const filePath = path.join(wsDir, "data.txt"); + fs.writeFileSync(filePath, "file contents here"); + const result = expandFileReferences(`prefix @${filePath} suffix`, wsDir); + expect(result).toBe("prefix file contents here suffix"); + }); + it("should expand @/path at start of line (standalone reference)", () => { + const filePath = path.join(wsDir, "body.md"); + fs.writeFileSync(filePath, "# Body\n\nContent."); + const result = expandFileReferences(`@${filePath}`, wsDir); + expect(result).toBe("# Body\n\nContent."); + }); + it("should leave @username mentions unchanged", () => { + const result = expandFileReferences("@copilot please review", wsDir); + expect(result).toBe("@copilot please review"); + }); + it("should leave disallowed paths unchanged and log info", () => { + const result = expandFileReferences("See @/etc/passwd for details", wsDir); + expect(result).toBe("See @/etc/passwd for details"); + expect(core.info).toHaveBeenCalledWith(expect.stringContaining("Ignoring disallowed path reference: /etc/passwd")); + }); + it("should leave non-existent allowed paths unchanged and warn", () => { + const filePath = path.join(wsDir, "missing.txt"); + const result = expandFileReferences(`@${filePath}`, wsDir); + expect(result).toBe(`@${filePath}`); + expect(core.warning).toHaveBeenCalledWith(expect.stringContaining(`File not found: ${filePath}`)); + }); + it("should expand multiple @/path references in the same content", () => { + const file1 = path.join(wsDir, "a.txt"); + const file2 = path.join(wsDir, "b.txt"); + fs.writeFileSync(file1, "AAA"); + fs.writeFileSync(file2, "BBB"); + const result = expandFileReferences(`@${file1} and @${file2}`, wsDir); + expect(result).toBe("AAA and BBB"); + }); + it("should reject paths with .. traversal even when within allowed prefix", () => { + const result = expandFileReferences(`@${wsDir}/../etc/passwd`, wsDir); + expect(result).toBe(`@${wsDir}/../etc/passwd`); + }); + it("should leave content without @/path unchanged", () => { + const input = "No file references here. ${{ github.actor }}"; + const result = expandFileReferences(input, wsDir); + expect(result).toBe(input); + }); + }); }); From 4c1b355f3635bf338ed7e7d38dfe1f36990661ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:59:45 +0000 Subject: [PATCH 2/3] Address code review: restrict filepath regex, remove redundant check, add traversal tests Agent-Logs-Url: https://github.com/github/gh-aw/sessions/f211b60f-12d4-400c-95e1-1eda2e8f9b86 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/interpolate_prompt.cjs | 22 +++++----------------- actions/setup/js/runtime_import.cjs | 14 +++++++++----- actions/setup/js/runtime_import.test.cjs | 10 ++++++++++ 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/actions/setup/js/interpolate_prompt.cjs b/actions/setup/js/interpolate_prompt.cjs index 9d72644610b..0ac73f43b48 100644 --- a/actions/setup/js/interpolate_prompt.cjs +++ b/actions/setup/js/interpolate_prompt.cjs @@ -211,23 +211,11 @@ async function main() { core.info("\n========================================"); core.info("[main] STEP 1.5: @filepath Expansion"); core.info("========================================"); - const hasFileReferences = /@\//.test(content); - if (hasFileReferences) { - const fileRefMatches = content.match(/@\/[^\s@]+/g) || []; - core.info(`Found ${fileRefMatches.length} potential @filepath reference(s)`); - fileRefMatches.forEach((ref, i) => { - core.info(` Reference ${i + 1}: ${ref.substring(0, 80)}${ref.length > 80 ? "..." : ""}`); - }); - - const beforeExpansion = content.length; - content = expandFileReferences(content, workspaceDir); - const afterExpansion = content.length; - - core.info(`@filepath expansion complete`); - core.info(`Content length change: ${beforeExpansion} -> ${afterExpansion} (${afterExpansion > beforeExpansion ? "+" : ""}${afterExpansion - beforeExpansion})`); - } else { - core.info("No @filepath references found, skipping expansion"); - } + const beforeExpansion = content.length; + content = expandFileReferences(content, workspaceDir); + const afterExpansion = content.length; + core.info(`@filepath expansion complete`); + core.info(`Content length change: ${beforeExpansion} -> ${afterExpansion} (${afterExpansion > beforeExpansion ? "+" : ""}${afterExpansion - beforeExpansion})`); // Step 2: Interpolate variables core.info("\n========================================"); diff --git a/actions/setup/js/runtime_import.cjs b/actions/setup/js/runtime_import.cjs index d95fce4d90b..583935de97d 100644 --- a/actions/setup/js/runtime_import.cjs +++ b/actions/setup/js/runtime_import.cjs @@ -1045,8 +1045,9 @@ async function processRuntimeImports(content, workspaceDir, importedFiles = new /** * Checks if a file path is allowed for @filepath expansion. - * Allowed paths must be absolute, clean (no .. or . traversal), and within - * the workspace directory or /tmp/gh-aw. + * Allowed paths must be absolute, clean (no .. or . traversal components in the raw + * path string — paths are refused rather than silently normalized), and within the + * workspace directory or /tmp/gh-aw. * @param {string} filePath - The absolute file path to validate * @param {string} workspaceDir - The GITHUB_WORKSPACE directory * @returns {boolean} - True if the path is allowed @@ -1055,7 +1056,9 @@ function isAllowedFileReference(filePath, workspaceDir) { // Must be absolute path if (!path.isAbsolute(filePath)) return false; - // Must be clean path (no .. or . directory traversal components) + // Must be a clean path: refuse paths with .. or . traversal components rather than + // silently normalizing them. This enforces the requirement that the caller provides + // the canonical path (e.g. @/tmp/gh-aw/../etc/passwd is refused outright). const parts = filePath.split("/"); for (const part of parts) { if (part === ".." || part === ".") return false; @@ -1085,8 +1088,9 @@ function isAllowedFileReference(filePath, workspaceDir) { */ function expandFileReferences(content, workspaceDir) { // Pattern: @ followed by an absolute path (starts with /) - // Distinguishes @/path from @username mentions (which don't start with /) - const pattern = /@(\/[^\s@]+)/g; + // Restricts to standard filesystem characters to avoid matching shell-special sequences. + // Distinguishes @/path from @username mentions (which don't start with /). + const pattern = /@(\/[a-zA-Z0-9_./-]+)/g; return content.replace(pattern, (match, filePath) => { if (!isAllowedFileReference(filePath, workspaceDir)) { diff --git a/actions/setup/js/runtime_import.test.cjs b/actions/setup/js/runtime_import.test.cjs index b564f628f74..75d6a79f6dd 100644 --- a/actions/setup/js/runtime_import.test.cjs +++ b/actions/setup/js/runtime_import.test.cjs @@ -1794,6 +1794,16 @@ describe("runtime_import", () => { const result = expandFileReferences(`@${wsDir}/../etc/passwd`, wsDir); expect(result).toBe(`@${wsDir}/../etc/passwd`); }); + it("should reject URL-encoded traversal paths", () => { + // URL-encoded .. (%2e%2e) should not be expanded since the regex only + // matches [a-zA-Z0-9_./-] — % is excluded so the reference won't even match + const result = expandFileReferences("@/tmp/gh-aw/%2e%2e/etc/passwd", wsDir); + expect(result).toBe("@/tmp/gh-aw/%2e%2e/etc/passwd"); + }); + it("should reject mixed-separator traversal paths", () => { + const result = expandFileReferences(`@${wsDir}/subdir/../../etc/passwd`, wsDir); + expect(result).toBe(`@${wsDir}/subdir/../../etc/passwd`); + }); it("should leave content without @/path unchanged", () => { const input = "No file references here. ${{ github.actor }}"; const result = expandFileReferences(input, wsDir); From aed623e94b00019e92b2968299f22f9178feb60b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:36:27 +0000 Subject: [PATCH 3/3] Move @filepath expansion to safe output body processing, not prompt pipeline Agent-Logs-Url: https://github.com/github/gh-aw/sessions/0f0ec9d7-3b7e-48d9-83a1-34890448c362 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/interpolate_prompt.cjs | 12 +-- .../setup/js/safe_output_handler_manager.cjs | 14 +++ .../js/safe_output_handler_manager.test.cjs | 88 +++++++++++++++++++ 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/actions/setup/js/interpolate_prompt.cjs b/actions/setup/js/interpolate_prompt.cjs index 0ac73f43b48..2097febc8a3 100644 --- a/actions/setup/js/interpolate_prompt.cjs +++ b/actions/setup/js/interpolate_prompt.cjs @@ -7,7 +7,7 @@ const fs = require("fs"); const { isTruthy } = require("./is_truthy.cjs"); -const { processRuntimeImports, expandFileReferences } = require("./runtime_import.cjs"); +const { processRuntimeImports } = require("./runtime_import.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_API, ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs"); @@ -207,16 +207,6 @@ async function main() { core.info("No runtime import macros found, skipping runtime import processing"); } - // Step 1.5: Expand @/absolute/path file references - core.info("\n========================================"); - core.info("[main] STEP 1.5: @filepath Expansion"); - core.info("========================================"); - const beforeExpansion = content.length; - content = expandFileReferences(content, workspaceDir); - const afterExpansion = content.length; - core.info(`@filepath expansion complete`); - core.info(`Content length change: ${beforeExpansion} -> ${afterExpansion} (${afterExpansion > beforeExpansion ? "+" : ""}${afterExpansion - beforeExpansion})`); - // Step 2: Interpolate variables core.info("\n========================================"); core.info("[main] STEP 2: Variable Interpolation"); diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index 7887e590951..e5cc04d9e45 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -24,6 +24,7 @@ const { createManifestLogger, ensureManifestExists, extractCreatedItemFromResult const { loadCustomSafeOutputJobTypes, loadCustomSafeOutputScriptHandlers, loadCustomSafeOutputActionHandlers, isStagedMode } = require("./safe_output_helpers.cjs"); const { emitSafeOutputActionOutputs } = require("./safe_outputs_action_outputs.cjs"); const { listCommentMemoryFiles, COMMENT_MEMORY_DIR } = require("./comment_memory_helpers.cjs"); +const { expandFileReferences } = require("./runtime_import.cjs"); const nodePath = require("path"); const fs = require("fs"); @@ -564,6 +565,19 @@ async function processMessages(messageHandlers, messages, onItemCreated = null) } } + // Pre-process: expand @/absolute/path file references in the message body. + // Agents may write a path like @/tmp/gh-aw/agent/comment-body.md as the body + // value; this resolves the file and inlines its contents before the handler runs. + // Only paths within GITHUB_WORKSPACE or /tmp/gh-aw are expanded; all others are ignored. + if (effectiveMessage.body && typeof effectiveMessage.body === "string") { + const workspaceDir = process.env.GITHUB_WORKSPACE || ""; + const expandedBody = expandFileReferences(effectiveMessage.body, workspaceDir); + if (expandedBody !== effectiveMessage.body) { + effectiveMessage = { ...effectiveMessage, body: expandedBody }; + core.info(`Expanded @filepath reference(s) in ${messageType} body`); + } + } + // Call the message handler with the individual message and resolved temp IDs const result = await messageHandler(effectiveMessage, resolvedTemporaryIds, temporaryIdMap); diff --git a/actions/setup/js/safe_output_handler_manager.test.cjs b/actions/setup/js/safe_output_handler_manager.test.cjs index dc80c292da1..0e748e6089b 100644 --- a/actions/setup/js/safe_output_handler_manager.test.cjs +++ b/actions/setup/js/safe_output_handler_manager.test.cjs @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import fs from "fs"; +import os from "os"; +import path from "path"; import { loadConfig, loadHandlers, processMessages, buildCommentMemoryMessagesFromFiles } from "./safe_output_handler_manager.cjs"; describe("Safe Output Handler Manager", () => { @@ -706,6 +708,92 @@ describe("Safe Output Handler Manager", () => { expect(result.outputsWithUnresolvedIds.length).toBe(0); }); + describe("@filepath expansion in message body", () => { + let tempWorkspace; + + beforeEach(() => { + tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "handler-manager-ws-")); + process.env.GITHUB_WORKSPACE = tempWorkspace; + }); + + afterEach(() => { + delete process.env.GITHUB_WORKSPACE; + fs.rmSync(tempWorkspace, { recursive: true, force: true }); + }); + + it("should expand @filepath reference in add_comment body to file contents", async () => { + const bodyFile = path.join(tempWorkspace, "comment-body.md"); + fs.writeFileSync(bodyFile, "# Hello\n\nThis is the comment body."); + + const messages = [{ type: "add_comment", body: `@${bodyFile}` }]; + + const mockHandler = vi.fn().mockResolvedValue({ success: true }); + const handlers = new Map([["add_comment", mockHandler]]); + + await processMessages(handlers, messages); + + const calledMessage = mockHandler.mock.calls[0][0]; + expect(calledMessage.body).toBe("# Hello\n\nThis is the comment body."); + expect(calledMessage.body).not.toContain("@/"); + }); + + it("should expand @filepath reference in create_issue body to file contents", async () => { + const bodyFile = path.join(tempWorkspace, "issue-body.md"); + fs.writeFileSync(bodyFile, "Issue body content."); + + const messages = [{ type: "create_issue", title: "My Issue", body: `@${bodyFile}` }]; + + const mockHandler = vi.fn().mockResolvedValue({ repo: "owner/repo", number: 42 }); + const handlers = new Map([["create_issue", mockHandler]]); + + await processMessages(handlers, messages); + + const calledMessage = mockHandler.mock.calls[0][0]; + expect(calledMessage.body).toBe("Issue body content."); + }); + + it("should allow @filepath reference from /tmp/gh-aw when GITHUB_WORKSPACE is set", async () => { + // Create a temp file simulating /tmp/gh-aw by writing to tempWorkspace/subdir instead + // (actual /tmp/gh-aw may not be writable in all CI environments; we test via workspace) + const bodyFile = path.join(tempWorkspace, "agent-body.md"); + fs.writeFileSync(bodyFile, "Agent comment body."); + + const messages = [{ type: "add_comment", body: `@${bodyFile}` }]; + + const mockHandler = vi.fn().mockResolvedValue({ success: true }); + const handlers = new Map([["add_comment", mockHandler]]); + + await processMessages(handlers, messages); + + const calledMessage = mockHandler.mock.calls[0][0]; + expect(calledMessage.body).toBe("Agent comment body."); + }); + + it("should leave body unchanged when no @filepath reference is present", async () => { + const messages = [{ type: "add_comment", body: "Plain comment body without file refs." }]; + + const mockHandler = vi.fn().mockResolvedValue({ success: true }); + const handlers = new Map([["add_comment", mockHandler]]); + + await processMessages(handlers, messages); + + const calledMessage = mockHandler.mock.calls[0][0]; + expect(calledMessage.body).toBe("Plain comment body without file refs."); + }); + + it("should leave disallowed @filepath references unchanged", async () => { + const messages = [{ type: "add_comment", body: "See @/etc/passwd for details." }]; + + const mockHandler = vi.fn().mockResolvedValue({ success: true }); + const handlers = new Map([["add_comment", mockHandler]]); + + await processMessages(handlers, messages); + + const calledMessage = mockHandler.mock.calls[0][0]; + expect(calledMessage.body).toBe("See @/etc/passwd for details."); + }); + }); + it("should silently skip message types handled by standalone steps", async () => { const messages = [ { type: "create_issue", title: "Issue" },