From b20d90fb299de490508ef3fac692a16cb231fd15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:09:54 +0000 Subject: [PATCH 1/2] Initial plan From 6a1f5d76738439f8a52f39a0d918405a22f9a11c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:28:32 +0000 Subject: [PATCH 2/2] Add safe_outputs body_file support for update handlers Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/safe-outputs-automation.md | 3 + .github/aw/safe-outputs-management.md | 3 + actions/setup/js/body_file_helpers.cjs | 132 ++++++++++++++++++ actions/setup/js/body_file_helpers.test.cjs | 79 +++++++++++ .../setup/js/safe_output_type_validator.cjs | 77 ++++++---- .../js/safe_output_type_validator.test.cjs | 34 ++++- actions/setup/js/safe_outputs_tools.json | 36 ++++- actions/setup/js/update_handler_factory.cjs | 28 ++++ .../setup/js/update_handler_factory.test.cjs | 72 ++++++++++ .../compiler_safe_outputs_config_test.go | 7 + pkg/workflow/js/safe_outputs_tools.json | 36 ++++- .../safe_output_validation_config_test.go | 30 +++- pkg/workflow/safe_outputs_handler_registry.go | 3 + .../safe_outputs_validation_config.go | 12 +- pkg/workflow/tool_description_enhancer.go | 9 ++ pkg/workflow/update_discussion.go | 2 + pkg/workflow/update_issue.go | 2 + pkg/workflow/update_pull_request.go | 2 + 18 files changed, 525 insertions(+), 42 deletions(-) create mode 100644 actions/setup/js/body_file_helpers.cjs create mode 100644 actions/setup/js/body_file_helpers.test.cjs diff --git a/.github/aw/safe-outputs-automation.md b/.github/aw/safe-outputs-automation.md index aac49a28fff..1b0ddc3bbc2 100644 --- a/.github/aw/safe-outputs-automation.md +++ b/.github/aw/safe-outputs-automation.md @@ -11,6 +11,7 @@ description: Safe-output reference for workflow dispatch, code scanning, checks, update-discussion: title: true # Optional: enable title updates body: true # Optional: enable body updates + body-file: true # Optional: opt in to body_file + body_sha256 under RUNNER_TEMP/gh-aw-safe/ labels: true # Optional: enable label updates allowed-labels: [status, type] # Optional: restrict to specific labels max: 1 # Optional: max updates (default: 1) @@ -18,6 +19,8 @@ description: Safe-output reference for workflow dispatch, code scanning, checks, target-repo: "owner/repo" # Optional: cross-repository ``` + When `body-file: true` is enabled, the agent may send `body_file` plus `body_sha256` instead of inline `body`. The file must stay under `$RUNNER_TEMP/gh-aw-safe/`, must be UTF-8 text, cannot be a symlink, is read exactly once at execution time, and must match the supplied SHA-256 digest. Inline `body` and `body_file` are mutually exclusive. + - `update-release:` - Update GitHub release descriptions ```yaml diff --git a/.github/aw/safe-outputs-management.md b/.github/aw/safe-outputs-management.md index cfc62f7786f..ed906f48f9f 100644 --- a/.github/aw/safe-outputs-management.md +++ b/.github/aw/safe-outputs-management.md @@ -13,6 +13,7 @@ description: Safe-output reference for update, label, milestone, project, releas target: "*" # Optional: target for updates (default: "triggering") title: true # Optional: allow updating issue title body: true # Optional: allow updating issue body + body-file: true # Optional: opt in to body_file + body_sha256 under RUNNER_TEMP/gh-aw-safe/ max: 3 # Optional: maximum number of issues to update (default: 1) target-repo: "owner/repo" # Optional: cross-repository ``` @@ -25,6 +26,7 @@ description: Safe-output reference for update, label, milestone, project, releas update-pull-request: title: true # Optional: enable title updates (default: true) body: true # Optional: enable body updates (default: true) + body-file: true # Optional: opt in to body_file + body_sha256 under RUNNER_TEMP/gh-aw-safe/ operation: "replace" # Optional: "replace" (default), "append", "prepend" update-branch: false # Optional: update PR branch with latest base before updates (default: false) max: 1 # Optional: max updates (default: 1) @@ -33,6 +35,7 @@ description: Safe-output reference for update, label, milestone, project, releas ``` Operation types: `replace` (default), `append`, `prepend`. + When `body-file: true` is enabled, the agent may send `body_file` plus `body_sha256` instead of inline `body`. The file must stay under `$RUNNER_TEMP/gh-aw-safe/`, must be UTF-8 text, cannot be a symlink, is read exactly once at execution time, and must match the supplied SHA-256 digest. Inline `body` and `body_file` are mutually exclusive. - `merge-pull-request:` - Merge pull requests under configured policy gates (experimental) ```yaml diff --git a/actions/setup/js/body_file_helpers.cjs b/actions/setup/js/body_file_helpers.cjs new file mode 100644 index 00000000000..a5dadcbd6a9 --- /dev/null +++ b/actions/setup/js/body_file_helpers.cjs @@ -0,0 +1,132 @@ +// @ts-check + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const { getErrorMessage } = require("./error_helpers.cjs"); +const { lstatGuard } = require("./symlink_guard.cjs"); + +const SAFE_BODY_FILE_DIRNAME = "gh-aw-safe"; +const MAX_BODY_FILE_BYTES = 65536; + +function getSafeBodyFileRoot() { + const runnerTemp = process.env.RUNNER_TEMP || "/tmp"; + return path.resolve(runnerTemp, SAFE_BODY_FILE_DIRNAME); +} + +function normalizeAuditPath(filePath) { + const allowlistedRoot = getSafeBodyFileRoot(); + const relative = path.relative(allowlistedRoot, filePath); + return relative && relative !== "." ? `${SAFE_BODY_FILE_DIRNAME}/${relative.split(path.sep).join("/")}` : SAFE_BODY_FILE_DIRNAME; +} + +function ensurePathHasNoSymlinks(candidatePath, allowlistedRoot) { + const relative = path.relative(allowlistedRoot, candidatePath); + const segments = relative.split(path.sep).filter(Boolean); + let currentPath = allowlistedRoot; + + if (lstatGuard(currentPath) === null) { + throw new Error(`Rejected body_file '${normalizeAuditPath(candidatePath)}': allowlisted root is a symbolic link`); + } + + for (const segment of segments) { + currentPath = path.join(currentPath, segment); + if (lstatGuard(currentPath) === null) { + throw new Error(`Rejected body_file '${normalizeAuditPath(candidatePath)}': symbolic links are not allowed`); + } + } +} + +function resolveBodyFilePath(bodyFile) { + const rawValue = typeof bodyFile === "string" ? bodyFile.trim() : ""; + if (!rawValue) { + throw new Error("body_file must be a non-empty string"); + } + + const allowlistedRoot = getSafeBodyFileRoot(); + const candidatePath = path.isAbsolute(rawValue) ? path.resolve(rawValue) : path.resolve(process.env.RUNNER_TEMP || "/tmp", rawValue); + const relative = path.relative(allowlistedRoot, candidatePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Rejected body_file '${rawValue}': path must stay under ${SAFE_BODY_FILE_DIRNAME}/`); + } + if (!fs.existsSync(candidatePath)) { + throw new Error(`Rejected body_file '${rawValue}': file does not exist`); + } + + ensurePathHasNoSymlinks(candidatePath, allowlistedRoot); + + let stat; + try { + stat = fs.statSync(candidatePath); + } catch (error) { + throw new Error(`Rejected body_file '${rawValue}': ${getErrorMessage(error)}`); + } + if (!stat.isFile()) { + throw new Error(`Rejected body_file '${rawValue}': path is not a regular file`); + } + if (stat.size > MAX_BODY_FILE_BYTES) { + throw new Error(`Rejected body_file '${rawValue}': file is too large (${stat.size} bytes > ${MAX_BODY_FILE_BYTES} bytes)`); + } + + return candidatePath; +} + +function readBodyFileSnapshot(bodyFile, expectedSha256) { + const resolvedPath = resolveBodyFilePath(bodyFile); + const openFlags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0); + let fd; + try { + fd = fs.openSync(resolvedPath, openFlags); + } catch (error) { + throw new Error(`Rejected body_file '${bodyFile}': ${getErrorMessage(error)}`); + } + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile()) { + throw new Error(`Rejected body_file '${bodyFile}': path is not a regular file`); + } + if (stat.size > MAX_BODY_FILE_BYTES) { + throw new Error(`Rejected body_file '${bodyFile}': file is too large (${stat.size} bytes > ${MAX_BODY_FILE_BYTES} bytes)`); + } + let fileBytes; + try { + fileBytes = fs.readFileSync(fd); + } catch (error) { + throw new Error(`Rejected body_file '${bodyFile}': ${getErrorMessage(error)}`); + } + const digest = crypto.createHash("sha256").update(fileBytes).digest("hex"); + if (digest !== expectedSha256) { + throw new Error(`Rejected body_file '${bodyFile}': body_sha256 mismatch`); + } + if (fileBytes.includes(0)) { + throw new Error(`Rejected body_file '${bodyFile}': file must be UTF-8 text`); + } + + let content; + try { + content = new TextDecoder("utf-8", { fatal: true }).decode(fileBytes); + } catch { + throw new Error(`Rejected body_file '${bodyFile}': file must be UTF-8 text`); + } + + return { + content, + metadata: { + path: normalizeAuditPath(resolvedPath), + sha256: digest, + bytes: fileBytes.length, + }, + }; + } finally { + fs.closeSync(fd); + } +} + +module.exports = { + SAFE_BODY_FILE_DIRNAME, + MAX_BODY_FILE_BYTES, + getSafeBodyFileRoot, + readBodyFileSnapshot, + resolveBodyFilePath, +}; diff --git a/actions/setup/js/body_file_helpers.test.cjs b/actions/setup/js/body_file_helpers.test.cjs new file mode 100644 index 00000000000..f96a4438560 --- /dev/null +++ b/actions/setup/js/body_file_helpers.test.cjs @@ -0,0 +1,79 @@ +import crypto from "crypto"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +let helpers; +let runnerTemp; + +describe("body_file_helpers", () => { + beforeEach(async () => { + runnerTemp = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-body-file-")); + process.env.RUNNER_TEMP = runnerTemp; + helpers = await import("./body_file_helpers.cjs"); + fs.mkdirSync(helpers.getSafeBodyFileRoot(), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(runnerTemp, { recursive: true, force: true }); + delete process.env.RUNNER_TEMP; + }); + + it("reads an allowlisted UTF-8 file once and returns audit metadata", () => { + const filePath = path.join(helpers.getSafeBodyFileRoot(), "body.md"); + fs.writeFileSync(filePath, "hello body\n"); + const digest = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); + + const result = helpers.readBodyFileSnapshot("gh-aw-safe/body.md", digest); + + expect(result).toEqual({ + content: "hello body\n", + metadata: { + path: "gh-aw-safe/body.md", + sha256: digest, + bytes: 11, + }, + }); + }); + + it("rejects paths outside the allowlisted directory", () => { + const outsidePath = path.join(runnerTemp, "outside.md"); + fs.writeFileSync(outsidePath, "nope"); + const digest = crypto.createHash("sha256").update(fs.readFileSync(outsidePath)).digest("hex"); + + expect(() => helpers.readBodyFileSnapshot(outsidePath, digest)).toThrow(/path must stay under gh-aw-safe\//i); + }); + + it("rejects SHA-256 mismatches", () => { + const filePath = path.join(helpers.getSafeBodyFileRoot(), "body.md"); + fs.writeFileSync(filePath, "hello"); + + expect(() => helpers.readBodyFileSnapshot("gh-aw-safe/body.md", "a".repeat(64))).toThrow(/body_sha256 mismatch/i); + }); + + it("rejects binary files", () => { + const filePath = path.join(helpers.getSafeBodyFileRoot(), "body.bin"); + fs.writeFileSync(filePath, Buffer.from([0x68, 0x69, 0x00, 0xff])); + const digest = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); + + expect(() => helpers.readBodyFileSnapshot("gh-aw-safe/body.bin", digest)).toThrow(/UTF-8 text/i); + }); + + it("rejects oversized files", () => { + const filePath = path.join(helpers.getSafeBodyFileRoot(), "body.md"); + fs.writeFileSync(filePath, "a".repeat(helpers.MAX_BODY_FILE_BYTES + 1)); + const digest = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); + + expect(() => helpers.readBodyFileSnapshot("gh-aw-safe/body.md", digest)).toThrow(/file is too large/i); + }); + + it("rejects symlink escapes", () => { + const targetPath = path.join(runnerTemp, "outside.md"); + fs.writeFileSync(targetPath, "secret"); + fs.symlinkSync(targetPath, path.join(helpers.getSafeBodyFileRoot(), "body.md")); + const digest = crypto.createHash("sha256").update(fs.readFileSync(targetPath)).digest("hex"); + + expect(() => helpers.readBodyFileSnapshot("gh-aw-safe/body.md", digest)).toThrow(/symbolic links are not allowed/i); + }); +}); diff --git a/actions/setup/js/safe_output_type_validator.cjs b/actions/setup/js/safe_output_type_validator.cjs index 6e5a1a587eb..f9136bad62f 100644 --- a/actions/setup/js/safe_output_type_validator.cjs +++ b/actions/setup/js/safe_output_type_validator.cjs @@ -632,39 +632,66 @@ function executeCustomValidation(item, customValidation, lineNum, itemType) { return null; } - // Parse custom validation rule - if (customValidation.startsWith("requiresOneOf:")) { - const fields = customValidation.slice("requiresOneOf:".length).split(","); - const hasValidField = fields.some(field => item[field] !== undefined && item[field] !== false); - if (!hasValidField) { - return { - isValid: false, - error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, - }; + const rules = customValidation + .split(";") + .map(rule => rule.trim()) + .filter(Boolean); + for (const rule of rules) { + if (rule.startsWith("requiresOneOf:")) { + const fields = rule.slice("requiresOneOf:".length).split(","); + const hasValidField = fields.some(field => item[field] !== undefined && item[field] !== false); + if (!hasValidField) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires at least one of: ${fields.map(f => `'${f}'`).join(", ")} fields`, + }; + } } - } - if (customValidation === "startLineLessOrEqualLine") { - if (item.start_line !== undefined && item.line !== undefined) { - const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; - const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; - if (startLine > endLine) { + if (rule.startsWith("pairedFields:")) { + const [leftField, rightField] = rule.slice("pairedFields:".length).split(","); + const leftPresent = item[leftField] !== undefined; + const rightPresent = item[rightField] !== undefined; + if (leftPresent !== rightPresent) { return { isValid: false, - error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, + error: `Line ${lineNum}: ${itemType} requires '${leftField}' and '${rightField}' to be provided together`, }; } } - } - if (customValidation === "parentAndSubDifferent") { - // Normalize values for comparison - const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); - if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { - return { - isValid: false, - error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, - }; + if (rule.startsWith("mutuallyExclusive:")) { + const [leftField, rightField] = rule.slice("mutuallyExclusive:".length).split(","); + if (item[leftField] !== undefined && item[rightField] !== undefined) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} '${leftField}' and '${rightField}' cannot be used together`, + }; + } + } + + if (rule === "startLineLessOrEqualLine") { + if (item.start_line !== undefined && item.line !== undefined) { + const startLine = typeof item.start_line === "string" ? parseInt(item.start_line, 10) : item.start_line; + const endLine = typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if (startLine > endLine) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'start_line' must be less than or equal to 'line'`, + }; + } + } + } + + if (rule === "parentAndSubDifferent") { + // Normalize values for comparison + const normalizeValue = v => (typeof v === "string" ? v.toLowerCase() : v); + if (normalizeValue(item.parent_issue_number) === normalizeValue(item.sub_issue_number)) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'parent_issue_number' and 'sub_issue_number' must be different`, + }; + } } } diff --git a/actions/setup/js/safe_output_type_validator.test.cjs b/actions/setup/js/safe_output_type_validator.test.cjs index 53bba1dee42..d548ccfaa5b 100644 --- a/actions/setup/js/safe_output_type_validator.test.cjs +++ b/actions/setup/js/safe_output_type_validator.test.cjs @@ -54,11 +54,13 @@ const SAMPLE_VALIDATION_CONFIG = { }, update_issue: { defaultMax: 1, - customValidation: "requiresOneOf:status,title,body,labels,assignees,milestone", + customValidation: "requiresOneOf:status,title,body,body_file,labels,assignees,milestone;pairedFields:body_file,body_sha256;mutuallyExclusive:body,body_file", fields: { status: { type: "string", enum: ["open", "closed"] }, title: { type: "string", sanitize: true, maxLength: 128 }, body: { type: "string", sanitize: true, maxLength: 65000 }, + body_file: { type: "string", maxLength: 512 }, + body_sha256: { type: "string", pattern: "^[a-f0-9]{64}$", patternError: "must be a lowercase SHA-256 hex digest" }, labels: { type: "array" }, assignees: { type: "array", itemType: "string", itemSanitize: true, itemMaxLength: 39 }, milestone: { optionalPositiveInteger: true }, @@ -67,10 +69,12 @@ const SAMPLE_VALIDATION_CONFIG = { }, update_pull_request: { defaultMax: 1, - customValidation: "requiresOneOf:title,body,update_branch", + customValidation: "requiresOneOf:title,body,body_file,update_branch;pairedFields:body_file,body_sha256;mutuallyExclusive:body,body_file", fields: { title: { type: "string", sanitize: true, maxLength: 256 }, body: { type: "string", sanitize: true, maxLength: 65000 }, + body_file: { type: "string", maxLength: 512 }, + body_sha256: { type: "string", pattern: "^[a-f0-9]{64}$", patternError: "must be a lowercase SHA-256 hex digest" }, update_branch: { type: "boolean" }, pull_request_number: { issueOrPRNumber: true }, }, @@ -946,6 +950,32 @@ describe("safe_output_type_validator", () => { expect(result.error).toContain("requires at least one of"); }); + it("should pass when update_issue only includes a file-backed body", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "update_issue", body_file: "gh-aw-safe/body.md", body_sha256: "a".repeat(64) }, "update_issue", 1); + + expect(result.isValid).toBe(true); + }); + + it("should fail when body_file is provided without body_sha256", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "update_issue", body_file: "gh-aw-safe/body.md" }, "update_issue", 1); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("provided together"); + }); + + it("should fail when body and body_file are both provided", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "update_pull_request", body: "inline", body_file: "gh-aw-safe/body.md", body_sha256: "a".repeat(64) }, "update_pull_request", 1); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("cannot be used together"); + }); + it("should pass for assign_to_agent with issue_number", async () => { const { validateItem } = await import("./safe_output_type_validator.cjs"); diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 90d3ad81255..bbe6734db7b 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -140,9 +140,19 @@ }, "body": { "type": "string", - "description": "New discussion body to replace the existing content. Use Markdown formatting.", + "description": "New discussion body to replace the existing content. Use Markdown formatting. Cannot be combined with body_file.", "maxLength": 65536 }, + "body_file": { + "type": "string", + "description": "Optional UTF-8 body file path under RUNNER_TEMP/gh-aw-safe/ (for example 'gh-aw-safe/body.md'). Only available when safe-outputs.update-discussion.body-file is enabled. Use together with body_sha256 instead of inline body. The file is read exactly once at execution time, verified against body_sha256, then sanitized with the same policy as inline body content.", + "maxLength": 512 + }, + "body_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "Required when body_file is used. Lowercase SHA-256 hex digest of the exact body file bytes. The runtime rejects the update if the file content does not match this digest." + }, "labels": { "type": "array", "items": { @@ -1014,9 +1024,19 @@ }, "body": { "type": "string", - "description": "Issue body content in Markdown. For 'replace', this becomes the entire body. For 'append'/'prepend', this content is added with a separator and an attribution footer. For 'replace-island', only the run-specific section is updated.", + "description": "Issue body content in Markdown. For 'replace', this becomes the entire body. For 'append'/'prepend', this content is added with a separator and an attribution footer. For 'replace-island', only the run-specific section is updated. Cannot be combined with body_file.", "maxLength": 65536 }, + "body_file": { + "type": "string", + "description": "Optional UTF-8 body file path under RUNNER_TEMP/gh-aw-safe/ (for example 'gh-aw-safe/body.md'). Only available when safe-outputs.update-issue.body-file is enabled. Use together with body_sha256 instead of inline body. The file is read exactly once at execution time, verified against body_sha256, then sanitized with the same policy as inline body content.", + "maxLength": 512 + }, + "body_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "Required when body_file is used. Lowercase SHA-256 hex digest of the exact body file bytes. The runtime rejects the update if the file content does not match this digest." + }, "operation": { "type": "string", "enum": ["replace", "append", "prepend", "replace-island"], @@ -1098,9 +1118,19 @@ }, "body": { "type": "string", - "description": "Pull request body content in Markdown. For 'replace', this becomes the entire body. For 'append'/'prepend', this is added with a separator.", + "description": "Pull request body content in Markdown. For 'replace', this becomes the entire body. For 'append'/'prepend', this is added with a separator. Cannot be combined with body_file.", "maxLength": 65536 }, + "body_file": { + "type": "string", + "description": "Optional UTF-8 body file path under RUNNER_TEMP/gh-aw-safe/ (for example 'gh-aw-safe/body.md'). Only available when safe-outputs.update-pull-request.body-file is enabled. Use together with body_sha256 instead of inline body. The file is read exactly once at execution time, verified against body_sha256, then sanitized with the same policy as inline body content.", + "maxLength": 512 + }, + "body_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "Required when body_file is used. Lowercase SHA-256 hex digest of the exact body file bytes. The runtime rejects the update if the file content does not match this digest." + }, "operation": { "type": "string", "enum": ["replace", "append", "prepend"], diff --git a/actions/setup/js/update_handler_factory.cjs b/actions/setup/js/update_handler_factory.cjs index e635a4650e0..e4d4b2830ca 100644 --- a/actions/setup/js/update_handler_factory.cjs +++ b/actions/setup/js/update_handler_factory.cjs @@ -14,6 +14,7 @@ const { sanitizeContent } = require("./sanitize_content.cjs"); const { attachExecutionState } = require("./safe_output_execution_metadata.cjs"); const { withRetry, isTransientError } = require("./error_recovery.cjs"); const { loadTemporaryIdMapFromResolved, resolveRepoIssueTarget } = require("./temporary_id.cjs"); +const { readBodyFileSnapshot } = require("./body_file_helpers.cjs"); /** * @typedef {Object} UpdateHandlerConfig @@ -170,6 +171,8 @@ function createUpdateHandlerFactory(handlerConfig) { processedCount++; const item = message; + /** @type {{ path: string, sha256: string, bytes: number } | null} */ + let bodyFileMetadata = null; // Resolve cross-repo target: always validate the target repository against the // allowed repos and use it as the effective context. When item.repo is set it @@ -217,6 +220,30 @@ function createUpdateHandlerFactory(handlerConfig) { const itemNumber = itemNumberResult.number; core.info(`Resolved target ${itemTypeName} #${itemNumber} (target config: ${updateTarget})`); + // Resolve file-backed body content into an immutable in-memory snapshot before + // any staged/unstaged branching so both modes observe identical validation. + if (item.body_file !== undefined) { + if (config.allow_body_file !== true) { + core.warning("body_file is not enabled for this workflow"); + return { + success: false, + error: "body_file is not enabled for this workflow; set safe-outputs..body-file: true to opt in", + }; + } + try { + const snapshot = readBodyFileSnapshot(item.body_file, item.body_sha256); + item.body = snapshot.content; + bodyFileMetadata = snapshot.metadata; + } catch (error) { + const errorMessage = getErrorMessage(error); + core.warning(errorMessage); + return { + success: false, + error: errorMessage, + }; + } + } + // Apply required-labels/required-title-prefix filter if configured if (itemFilter) { const filterResult = await itemFilter(githubClient, repoResult.repoParts, itemNumber, config); @@ -304,6 +331,7 @@ function createUpdateHandlerFactory(handlerConfig) { const result = { ...formatSuccessResult(itemNumber, updatedItem), repo: `${effectiveContext.repo.owner}/${effectiveContext.repo.repo}`, + ...(bodyFileMetadata ? { metadata: { body_file: bodyFileMetadata } } : {}), }; const afterState = captureExecutionMetadata?.captureAfter ? await captureExecutionMetadata.captureAfter(updatedItem, beforeState, updateData) : null; return attachExecutionState(result, beforeState, afterState); diff --git a/actions/setup/js/update_handler_factory.test.cjs b/actions/setup/js/update_handler_factory.test.cjs index 1d76f485513..2a3d56ba202 100644 --- a/actions/setup/js/update_handler_factory.test.cjs +++ b/actions/setup/js/update_handler_factory.test.cjs @@ -1,4 +1,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +import crypto from "crypto"; +import fs from "fs"; +import os from "os"; +import path from "path"; // Import the factory function let factoryModule; @@ -50,6 +54,7 @@ describe("update_handler_factory.cjs", () => { // Import the module fresh for each test factoryModule = await import("./update_handler_factory.cjs"); + delete process.env.RUNNER_TEMP; }); describe("createUpdateHandlerFactory", () => { @@ -294,6 +299,73 @@ describe("update_handler_factory.cjs", () => { expect(passedUpdateData._rawBody).not.toBe(unsafeBody); }); + it("should load, sanitize, and audit a file-backed body snapshot", async () => { + const runnerTemp = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-body-file-handler-")); + process.env.RUNNER_TEMP = runnerTemp; + const safeDir = path.join(runnerTemp, "gh-aw-safe"); + fs.mkdirSync(safeDir, { recursive: true }); + const bodyPath = path.join(safeDir, "body.md"); + fs.writeFileSync(bodyPath, "/run-command from file @bot-trigger"); + const digest = crypto.createHash("sha256").update(fs.readFileSync(bodyPath)).digest("hex"); + + const mockResolveItemNumber = vi.fn().mockReturnValue({ success: true, number: 42 }); + const mockBuildUpdateData = vi.fn().mockImplementation(item => ({ + success: true, + data: { _rawBody: item.body, _operation: "replace" }, + })); + const mockExecuteUpdate = vi.fn().mockResolvedValue({ html_url: "https://example.com/issues/42", title: "Updated" }); + const mockFormatSuccessResult = vi.fn().mockReturnValue({ success: true, number: 42, url: "https://example.com/issues/42" }); + + const handlerFactory = factoryModule.createUpdateHandlerFactory({ + itemType: "update_issue", + itemTypeName: "issue", + supportsPR: false, + resolveItemNumber: mockResolveItemNumber, + buildUpdateData: mockBuildUpdateData, + executeUpdate: mockExecuteUpdate, + formatSuccessResult: mockFormatSuccessResult, + }); + + const handler = await handlerFactory({ allow_body_file: true }); + const result = await handler({ body_file: "gh-aw-safe/body.md", body_sha256: digest }); + + expect(result.success).toBe(true); + expect(mockBuildUpdateData).toHaveBeenCalledWith(expect.objectContaining({ body: "/run-command from file @bot-trigger" }), expect.anything()); + const passedUpdateData = mockExecuteUpdate.mock.calls[0][3]; + expect(passedUpdateData._rawBody).not.toBe("/run-command from file @bot-trigger"); + expect(result.metadata).toEqual({ + body_file: { + path: "gh-aw-safe/body.md", + sha256: digest, + bytes: fs.readFileSync(bodyPath).length, + }, + }); + }); + + it("should reject body_file when the workflow has not opted in", async () => { + const mockResolveItemNumber = vi.fn().mockReturnValue({ success: true, number: 42 }); + const mockBuildUpdateData = vi.fn(); + const mockExecuteUpdate = vi.fn(); + const mockFormatSuccessResult = vi.fn(); + + const handlerFactory = factoryModule.createUpdateHandlerFactory({ + itemType: "update_issue", + itemTypeName: "issue", + supportsPR: false, + resolveItemNumber: mockResolveItemNumber, + buildUpdateData: mockBuildUpdateData, + executeUpdate: mockExecuteUpdate, + formatSuccessResult: mockFormatSuccessResult, + }); + + const handler = await handlerFactory({}); + const result = await handler({ body_file: "gh-aw-safe/body.md", body_sha256: "a".repeat(64) }); + + expect(result.success).toBe(false); + expect(result.error).toContain("body_file is not enabled"); + expect(mockBuildUpdateData).not.toHaveBeenCalled(); + }); + it("should handle execution errors gracefully", async () => { const mockResolveItemNumber = vi.fn().mockReturnValue({ success: true, number: 42 }); const mockBuildUpdateData = vi.fn().mockReturnValue({ success: true, data: { title: "Test" } }); diff --git a/pkg/workflow/compiler_safe_outputs_config_test.go b/pkg/workflow/compiler_safe_outputs_config_test.go index 497da3b82f7..545dae1759d 100644 --- a/pkg/workflow/compiler_safe_outputs_config_test.go +++ b/pkg/workflow/compiler_safe_outputs_config_test.go @@ -1324,6 +1324,13 @@ func TestHandlerConfigUpdateFields(t *testing.T) { }, expectedKeys: []string{"allow_title", "allow_body"}, }, + { + name: "body file opt-in", + config: &UpdateIssuesConfig{ + BodyFile: testBoolPtr(true), + }, + expectedKeys: []string{"allow_body_file"}, + }, } for _, tt := range tests { diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 33045c6cce5..b0c481aae42 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -165,9 +165,19 @@ }, "body": { "type": "string", - "description": "New discussion body to replace the existing content. Use Markdown formatting.", + "description": "New discussion body to replace the existing content. Use Markdown formatting. Cannot be combined with body_file.", "maxLength": 65536 }, + "body_file": { + "type": "string", + "description": "Optional UTF-8 body file path under RUNNER_TEMP/gh-aw-safe/ (for example 'gh-aw-safe/body.md'). Only available when safe-outputs.update-discussion.body-file is enabled. Use together with body_sha256 instead of inline body. The file is read exactly once at execution time, verified against body_sha256, then sanitized with the same policy as inline body content.", + "maxLength": 512 + }, + "body_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "Required when body_file is used. Lowercase SHA-256 hex digest of the exact body file bytes. The runtime rejects the update if the file content does not match this digest." + }, "labels": { "type": "array", "items": { @@ -1285,9 +1295,19 @@ }, "body": { "type": "string", - "description": "Issue body content in Markdown. For 'replace', this becomes the entire body. For 'append'/'prepend', this content is added with a separator and an attribution footer. For 'replace-island', only the run-specific section is updated.", + "description": "Issue body content in Markdown. For 'replace', this becomes the entire body. For 'append'/'prepend', this content is added with a separator and an attribution footer. For 'replace-island', only the run-specific section is updated. Cannot be combined with body_file.", "maxLength": 65536 }, + "body_file": { + "type": "string", + "description": "Optional UTF-8 body file path under RUNNER_TEMP/gh-aw-safe/ (for example 'gh-aw-safe/body.md'). Only available when safe-outputs.update-issue.body-file is enabled. Use together with body_sha256 instead of inline body. The file is read exactly once at execution time, verified against body_sha256, then sanitized with the same policy as inline body content.", + "maxLength": 512 + }, + "body_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "Required when body_file is used. Lowercase SHA-256 hex digest of the exact body file bytes. The runtime rejects the update if the file content does not match this digest." + }, "operation": { "type": "string", "enum": [ @@ -1388,9 +1408,19 @@ }, "body": { "type": "string", - "description": "Pull request body content in Markdown. For 'replace', this becomes the entire body. For 'append'/'prepend', this is added with a separator.", + "description": "Pull request body content in Markdown. For 'replace', this becomes the entire body. For 'append'/'prepend', this is added with a separator. Cannot be combined with body_file.", "maxLength": 65536 }, + "body_file": { + "type": "string", + "description": "Optional UTF-8 body file path under RUNNER_TEMP/gh-aw-safe/ (for example 'gh-aw-safe/body.md'). Only available when safe-outputs.update-pull-request.body-file is enabled. Use together with body_sha256 instead of inline body. The file is read exactly once at execution time, verified against body_sha256, then sanitized with the same policy as inline body content.", + "maxLength": 512 + }, + "body_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "Required when body_file is used. Lowercase SHA-256 hex digest of the exact body file bytes. The runtime rejects the update if the file content does not match this digest." + }, "operation": { "type": "string", "enum": [ diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go index b9db74409d8..5a5a5079c60 100644 --- a/pkg/workflow/safe_output_validation_config_test.go +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -280,14 +280,20 @@ func TestUpdateDiscussionValidationConfig(t *testing.T) { } // customValidation must include labels so label-only messages pass - if config.CustomValidation != "requiresOneOf:title,body,labels" { - t.Errorf("update_discussion customValidation = %q, want %q", config.CustomValidation, "requiresOneOf:title,body,labels") + if config.CustomValidation != "requiresOneOf:title,body,body_file,labels;pairedFields:body_file,body_sha256;mutuallyExclusive:body,body_file" { + t.Errorf("update_discussion customValidation = %q, want %q", config.CustomValidation, "requiresOneOf:title,body,body_file,labels;pairedFields:body_file,body_sha256;mutuallyExclusive:body,body_file") } // labels field must be defined so label values are validated if _, ok := config.Fields["labels"]; !ok { t.Error("update_discussion Fields is missing the 'labels' field") } + if _, ok := config.Fields["body_file"]; !ok { + t.Error("update_discussion Fields is missing the 'body_file' field") + } + if _, ok := config.Fields["body_sha256"]; !ok { + t.Error("update_discussion Fields is missing the 'body_sha256' field") + } } func TestUpdatePullRequestValidationConfig(t *testing.T) { @@ -296,13 +302,19 @@ func TestUpdatePullRequestValidationConfig(t *testing.T) { t.Fatal("update_pull_request not found in ValidationConfig") } - if config.CustomValidation != "requiresOneOf:title,body,update_branch" { - t.Errorf("update_pull_request customValidation = %q, want %q", config.CustomValidation, "requiresOneOf:title,body,update_branch") + if config.CustomValidation != "requiresOneOf:title,body,body_file,update_branch;pairedFields:body_file,body_sha256;mutuallyExclusive:body,body_file" { + t.Errorf("update_pull_request customValidation = %q, want %q", config.CustomValidation, "requiresOneOf:title,body,body_file,update_branch;pairedFields:body_file,body_sha256;mutuallyExclusive:body,body_file") } if _, ok := config.Fields["update_branch"]; !ok { t.Error("update_pull_request Fields is missing the 'update_branch' field") } + if _, ok := config.Fields["body_file"]; !ok { + t.Error("update_pull_request Fields is missing the 'body_file' field") + } + if _, ok := config.Fields["body_sha256"]; !ok { + t.Error("update_pull_request Fields is missing the 'body_sha256' field") + } } func TestUpdateIssueValidationConfig(t *testing.T) { @@ -311,13 +323,19 @@ func TestUpdateIssueValidationConfig(t *testing.T) { t.Fatal("update_issue not found in ValidationConfig") } - if config.CustomValidation != "requiresOneOf:status,title,body,labels,assignees,milestone" { - t.Errorf("update_issue customValidation = %q, want %q", config.CustomValidation, "requiresOneOf:status,title,body,labels,assignees,milestone") + if config.CustomValidation != "requiresOneOf:status,title,body,body_file,labels,assignees,milestone;pairedFields:body_file,body_sha256;mutuallyExclusive:body,body_file" { + t.Errorf("update_issue customValidation = %q, want %q", config.CustomValidation, "requiresOneOf:status,title,body,body_file,labels,assignees,milestone;pairedFields:body_file,body_sha256;mutuallyExclusive:body,body_file") } if _, ok := config.Fields["labels"]; !ok { t.Error("update_issue Fields is missing the 'labels' field") } + if _, ok := config.Fields["body_file"]; !ok { + t.Error("update_issue Fields is missing the 'body_file' field") + } + if _, ok := config.Fields["body_sha256"]; !ok { + t.Error("update_issue Fields is missing the 'body_sha256' field") + } } func TestIssueIntentValidationFields(t *testing.T) { diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index c9904adff6e..7801a52af5f 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -347,6 +347,7 @@ var handlerRegistry = map[string]handlerBuilder{ // Body uses boolean value mode - add the actual boolean value builder.AddBoolPtrOrDefault("allow_body", c.Body, true) return builder. + AddBoolPtr("allow_body_file", c.BodyFile). AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). @@ -373,6 +374,7 @@ var handlerRegistry = map[string]handlerBuilder{ builder.AddDefault("allow_labels", true) } return builder. + AddBoolPtr("allow_body_file", c.BodyFile). AddStringSlice("allowed_labels", c.AllowedLabels). AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). @@ -611,6 +613,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target", c.Target). AddBoolPtrOrDefault("allow_title", c.Title, true). AddBoolPtrOrDefault("allow_body", c.Body, true). + AddBoolPtr("allow_body_file", c.BodyFile). AddBoolPtrOrDefault("update_branch", c.UpdateBranch, false). AddStringPtr("default_operation", c.Operation). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)).AddStringSlice("required_labels", c.RequiredLabels). diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 59d096b3092..7cde582c442 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -189,11 +189,13 @@ var ValidationConfig = map[string]TypeValidationConfig{ }, "update_issue": { DefaultMax: 1, - CustomValidation: "requiresOneOf:status,title,body,labels,assignees,milestone", + CustomValidation: "requiresOneOf:status,title,body,body_file,labels,assignees,milestone;pairedFields:body_file,body_sha256;mutuallyExclusive:body,body_file", Fields: map[string]FieldValidation{ "status": {Type: "string", Enum: []string{"open", "closed"}}, "title": {Type: "string", Sanitize: true, MaxLength: 128}, "body": {Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "body_file": {Type: "string", MaxLength: 512}, + "body_sha256": {Type: "string", Pattern: "^[a-f0-9]{64}$", PatternError: "must be a lowercase SHA-256 hex digest"}, "operation": {Type: "string", Enum: []string{"replace", "append", "prepend", "replace-island"}}, "labels": {Type: "array"}, "assignees": {Type: "array", ItemType: "string", ItemSanitize: true, ItemMaxLength: MaxGitHubUsernameLength}, @@ -204,10 +206,12 @@ var ValidationConfig = map[string]TypeValidationConfig{ }, "update_pull_request": { DefaultMax: 1, - CustomValidation: "requiresOneOf:title,body,update_branch", + CustomValidation: "requiresOneOf:title,body,body_file,update_branch;pairedFields:body_file,body_sha256;mutuallyExclusive:body,body_file", Fields: map[string]FieldValidation{ "title": {Type: "string", Sanitize: true, MaxLength: 256}, "body": {Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "body_file": {Type: "string", MaxLength: 512}, + "body_sha256": {Type: "string", Pattern: "^[a-f0-9]{64}$", PatternError: "must be a lowercase SHA-256 hex digest"}, "operation": {Type: "string", Enum: []string{"replace", "append", "prepend"}}, "update_branch": {Type: "boolean"}, "draft": {Type: "boolean"}, @@ -396,10 +400,12 @@ var ValidationConfig = map[string]TypeValidationConfig{ }, "update_discussion": { DefaultMax: 1, - CustomValidation: "requiresOneOf:title,body,labels", + CustomValidation: "requiresOneOf:title,body,body_file,labels;pairedFields:body_file,body_sha256;mutuallyExclusive:body,body_file", Fields: map[string]FieldValidation{ "title": {Type: "string", Sanitize: true, MaxLength: 128}, "body": {Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "body_file": {Type: "string", MaxLength: 512}, + "body_sha256": {Type: "string", Pattern: "^[a-f0-9]{64}$", PatternError: "must be a lowercase SHA-256 hex digest"}, "labels": {Type: "array", ItemType: "string", ItemSanitize: true, ItemMaxLength: 128}, "discussion_number": {IssueOrPRNumber: true}, "repo": {Type: "string", MaxLength: 256}, // Optional: target repository in format "owner/repo" diff --git a/pkg/workflow/tool_description_enhancer.go b/pkg/workflow/tool_description_enhancer.go index 734f075acec..f39be47b79a 100644 --- a/pkg/workflow/tool_description_enhancer.go +++ b/pkg/workflow/tool_description_enhancer.go @@ -284,6 +284,9 @@ func updateDiscussionConstraints(config *UpdateDiscussionsConfig) []string { if config.Body != nil && *config.Body { constraints = append(constraints, "Body updates are allowed.") } + if config.BodyFile != nil && *config.BodyFile { + constraints = append(constraints, "File-backed body updates are allowed via body_file + body_sha256 under RUNNER_TEMP/gh-aw-safe/.") + } if config.Labels != nil { if len(config.AllowedLabels) > 0 { constraints = append(constraints, fmt.Sprintf("Only these labels are allowed: %s.", formatStringList(config.AllowedLabels))) @@ -583,6 +586,9 @@ func updateIssueConstraints(config *UpdateIssuesConfig) []string { if config.Body != nil && *config.Body { constraints = append(constraints, "Body updates are allowed.") } + if config.BodyFile != nil && *config.BodyFile { + constraints = append(constraints, "File-backed body updates are allowed via body_file + body_sha256 under RUNNER_TEMP/gh-aw-safe/.") + } if config.Status != nil && *config.Status { constraints = append(constraints, "Status updates (open/closed) are allowed.") } @@ -605,6 +611,9 @@ func updatePullRequestConstraints(config *UpdatePullRequestsConfig) []string { if config.RequiredTitlePrefix != "" { constraints = append(constraints, fmt.Sprintf("Only PRs with title prefix %q can be updated.", config.RequiredTitlePrefix)) } + if config.BodyFile != nil && *config.BodyFile { + constraints = append(constraints, "File-backed body updates are allowed via body_file + body_sha256 under RUNNER_TEMP/gh-aw-safe/.") + } return constraints } diff --git a/pkg/workflow/update_discussion.go b/pkg/workflow/update_discussion.go index 2baac7be695..853ad07ba6b 100644 --- a/pkg/workflow/update_discussion.go +++ b/pkg/workflow/update_discussion.go @@ -14,6 +14,7 @@ type UpdateDiscussionsConfig struct { UpdateEntityConfig `yaml:",inline"` Title *bool `yaml:"title,omitempty"` // Allow updating discussion title - presence indicates field can be updated Body *bool `yaml:"body,omitempty"` // Allow updating discussion body - presence indicates field can be updated + BodyFile *bool `yaml:"body-file,omitempty"` // When true, allow body_file/body_sha256 references under RUNNER_TEMP/gh-aw-safe/ Labels *bool `yaml:"labels,omitempty"` // Allow updating discussion labels - presence indicates field can be updated AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones). Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. @@ -27,6 +28,7 @@ func (c *Compiler) parseUpdateDiscussionsConfig(outputMap map[string]any) *Updat return []UpdateEntityFieldSpec{ {Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title}, {Name: "body", Mode: FieldParsingKeyExistence, Dest: &cfg.Body}, + {Name: "body-file", Mode: FieldParsingBoolValue, Dest: &cfg.BodyFile}, {Name: "labels", Mode: FieldParsingKeyExistence, Dest: &cfg.Labels}, {Name: "footer", Mode: FieldParsingTemplatableBool, StringDest: &cfg.Footer}, } diff --git a/pkg/workflow/update_issue.go b/pkg/workflow/update_issue.go index 4a904bd57bd..c7f34e4dd9a 100644 --- a/pkg/workflow/update_issue.go +++ b/pkg/workflow/update_issue.go @@ -15,6 +15,7 @@ type UpdateIssuesConfig struct { Status *bool `yaml:"status,omitempty"` // Allow updating issue status (open/closed) - presence indicates field can be updated Title *bool `yaml:"title,omitempty"` // Allow updating issue title - presence indicates field can be updated Body *bool `yaml:"body,omitempty"` // Allow updating issue body - boolean value controls permission (defaults to true) + BodyFile *bool `yaml:"body-file,omitempty"` // When true, allow body_file/body_sha256 references under RUNNER_TEMP/gh-aw-safe/ Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. TitlePrefix string `yaml:"title-prefix,omitempty"` // Required title prefix for issue validation - only issues with this prefix can be updated (deprecated: use required-title-prefix) RequiredTitlePrefix string `yaml:"required-title-prefix,omitempty"` // Title prefix the issue must have (preferred over title-prefix) @@ -31,6 +32,7 @@ func (c *Compiler) parseUpdateIssuesConfig(outputMap map[string]any) *UpdateIssu {Name: "status", Mode: FieldParsingKeyExistence, Dest: &cfg.Status}, {Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title}, {Name: "body", Mode: FieldParsingBoolValue, Dest: &cfg.Body}, + {Name: "body-file", Mode: FieldParsingBoolValue, Dest: &cfg.BodyFile}, {Name: "footer", Mode: FieldParsingTemplatableBool, StringDest: &cfg.Footer}, } }, func(configMap map[string]any, cfg *UpdateIssuesConfig) { diff --git a/pkg/workflow/update_pull_request.go b/pkg/workflow/update_pull_request.go index 698a516b6d8..ef26eca7897 100644 --- a/pkg/workflow/update_pull_request.go +++ b/pkg/workflow/update_pull_request.go @@ -15,6 +15,7 @@ type UpdatePullRequestsConfig struct { SafeOutputFilterConfig `yaml:",inline"` Title *bool `yaml:"title,omitempty"` // Allow updating PR title - defaults to true, set to false to disable Body *bool `yaml:"body,omitempty"` // Allow updating PR body - defaults to true, set to false to disable + BodyFile *bool `yaml:"body-file,omitempty"` // When true, allow body_file/body_sha256 references under RUNNER_TEMP/gh-aw-safe/ UpdateBranch *bool `yaml:"update-branch,omitempty"` // When true, update PR branch with latest base branch changes before applying other updates. Defaults to false. Operation *string `yaml:"operation,omitempty"` // Default operation for body updates: "append", "prepend", or "replace" (defaults to "replace") Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted. @@ -30,6 +31,7 @@ func (c *Compiler) parseUpdatePullRequestsConfig(outputMap map[string]any) *Upda return []UpdateEntityFieldSpec{ {Name: "title", Mode: FieldParsingBoolValue, Dest: &cfg.Title}, {Name: "body", Mode: FieldParsingBoolValue, Dest: &cfg.Body}, + {Name: "body-file", Mode: FieldParsingBoolValue, Dest: &cfg.BodyFile}, {Name: "update-branch", Mode: FieldParsingBoolValue, Dest: &cfg.UpdateBranch}, {Name: "footer", Mode: FieldParsingTemplatableBool, StringDest: &cfg.Footer}, }