diff --git a/actions/setup/js/add_comment.cjs b/actions/setup/js/add_comment.cjs index 90888bbd240..be660374d5f 100644 --- a/actions/setup/js/add_comment.cjs +++ b/actions/setup/js/add_comment.cjs @@ -17,7 +17,7 @@ const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_help const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { getMissingInfoSections } = require("./missing_messages_helper.cjs"); const { getMessages } = require("./messages_core.cjs"); -const { getBodyHeader } = require("./messages_header.cjs"); +const { getBodyHeader, getDisclosureHeader } = require("./messages_header.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { MAX_COMMENT_LENGTH, MAX_MENTIONS, MAX_LINKS, enforceCommentLimits } = require("./comment_limit_helpers.cjs"); const { createDiscussionComment, resolveTopLevelDiscussionCommentId } = require("./github_api_helpers.cjs"); @@ -669,11 +669,12 @@ async function main(config = {}) { runUrl, }).detectionCaution; - // Inject body header if configured (placed after caution, before user content) + // Inject body header and disclosure header if configured (placed after caution, before user content) const bodyHeader = getBodyHeader({ workflowName, runUrl }); + const disclosureHeader = getDisclosureHeader({ workflowName, runUrl }); - // Build prefix: caution (if any) → body header (if any) → user content - const prefixParts = [detectionCaution, bodyHeader].filter(Boolean); + // Build prefix: caution (if any) → disclosure header (if any) → body header (if any) → user content + const prefixParts = [detectionCaution, disclosureHeader, bodyHeader].filter(Boolean); if (prefixParts.length > 0) processedBody = prefixParts.join("\n\n") + "\n\n" + processedBody; // Add tracker ID and footer diff --git a/actions/setup/js/create_discussion.cjs b/actions/setup/js/create_discussion.cjs index cb81dfe66a0..43f0022bea6 100644 --- a/actions/setup/js/create_discussion.cjs +++ b/actions/setup/js/create_discussion.cjs @@ -18,7 +18,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_VALIDATION } = require("./error_codes.cjs"); const { createExpirationLine, generateFooterWithExpiration, addExpirationToFooter } = require("./ephemerals.cjs"); const { assembleMarkdownBodyParts } = require("./markdown_body_helpers.cjs"); -const { getBodyHeader } = require("./messages_header.cjs"); +const { getBodyHeader, getDisclosureHeader } = require("./messages_header.cjs"); const { generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKeyMarker, normalizeCloseOlderKey } = require("./generate_footer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); @@ -545,6 +545,12 @@ async function main(config = {}) { bodyLines.unshift(...bodyHeader.split("\n"), ""); } + // Inject disclosure header (this runs after body-header, but appears before it because unshift prepends) + const disclosureHeader = getDisclosureHeader({ workflowName, runUrl }); + if (disclosureHeader) { + bodyLines.unshift(...disclosureHeader.split("\n"), ""); + } + const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); const triggeringDiscussionNumber = context.payload?.discussion?.number; diff --git a/actions/setup/js/create_issue.cjs b/actions/setup/js/create_issue.cjs index 67528c2b24a..1d02c8f6994 100644 --- a/actions/setup/js/create_issue.cjs +++ b/actions/setup/js/create_issue.cjs @@ -5,7 +5,7 @@ const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { generateFooterWithMessages, getDetectionCautionAlert } = require("./messages_footer.cjs"); -const { getBodyHeader } = require("./messages_header.cjs"); +const { getBodyHeader, getDisclosureHeader } = require("./messages_header.cjs"); const { generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKeyMarker, normalizeCloseOlderKey } = require("./generate_footer.cjs"); const { generateHistoryUrl } = require("./generate_history_link.cjs"); const { getTrackerID } = require("./get_tracker_id.cjs"); @@ -883,6 +883,12 @@ async function main(config = {}) { bodyLines.unshift(...bodyHeader.split("\n"), ""); } + // Inject disclosure header (this runs after body-header, but appears before it because unshift prepends) + const disclosureHeader = getDisclosureHeader({ workflowName, runUrl }); + if (disclosureHeader) { + bodyLines.unshift(...disclosureHeader.split("\n"), ""); + } + // Inject CAUTION at top of body if threat detection warning was raised // (unshifted after header so it appears first in the final output) const detectionCaution = getDetectionCautionAlert(workflowName, runUrl); diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 82735b6f33a..fde8a1b329b 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -18,7 +18,7 @@ const { addExpirationToFooter } = require("./ephemerals.cjs"); const { generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKeyMarker, normalizeCloseOlderKey } = require("./generate_footer.cjs"); const { parseBoolTemplatable } = require("./templatable.cjs"); const { assembleMarkdownBodyParts } = require("./markdown_body_helpers.cjs"); -const { getBodyHeader } = require("./messages_header.cjs"); +const { getBodyHeader, getDisclosureHeader } = require("./messages_header.cjs"); const { generateHistoryUrl } = require("./generate_history_link.cjs"); const { normalizeBranchName } = require("./normalize_branch_name.cjs"); const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs"); @@ -1368,6 +1368,12 @@ async function main(config = {}) { bodyLines.unshift(...bodyHeader.split("\n"), ""); } + // Inject disclosure header (this runs after body-header, but appears before it because unshift prepends) + const disclosureHeader = getDisclosureHeader({ workflowName, runUrl }); + if (disclosureHeader) { + bodyLines.unshift(...disclosureHeader.split("\n"), ""); + } + // Keep the protected-files notice directly under detection caution: // this block runs first, then detectionCaution below unshifts to index 0. if (manifestProtectionRequestReview && manifestProtectionRequestReview.length > 0) { diff --git a/actions/setup/js/messages.cjs b/actions/setup/js/messages.cjs index 83120ed2215..67f8faa7cd9 100644 --- a/actions/setup/js/messages.cjs +++ b/actions/setup/js/messages.cjs @@ -36,7 +36,7 @@ const { getRunStartedMessage, getRunSuccessMessage, getRunFailureMessage } = req const { getCloseOlderDiscussionMessage } = require("./messages_close_discussion.cjs"); // Re-export body header messages -const { getBodyHeader } = require("./messages_header.cjs"); +const { getBodyHeader, getDisclosureHeader } = require("./messages_header.cjs"); module.exports = { getMessages, @@ -56,4 +56,5 @@ module.exports = { getRunFailureMessage, getCloseOlderDiscussionMessage, getBodyHeader, + getDisclosureHeader, }; diff --git a/actions/setup/js/messages_core.cjs b/actions/setup/js/messages_core.cjs index 26aa863fdba..73e163aa8d4 100644 --- a/actions/setup/js/messages_core.cjs +++ b/actions/setup/js/messages_core.cjs @@ -48,6 +48,7 @@ const fs = require("fs"); * @property {string} [agentFailureComment] - Custom footer template for comments on agent failure tracking issues * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated * @property {string} [bodyHeader] - Custom header text prepended to every message body (issues, comments, PRs, discussions). Placeholders: {workflow_name}, {run_url} + * @property {string|boolean} [disclosureHeader] - AI authorship disclosure header. Set true/"true" for built-in default or provide a custom template * @property {boolean} [appendOnlyComments] - If true, create new comments instead of updating the activation comment * @property {string|boolean} [activationComments] - If false or "false", disable all activation/fallback comments entirely. Supports templatable boolean values (default: true) */ diff --git a/actions/setup/js/messages_header.cjs b/actions/setup/js/messages_header.cjs index 415e0d8d4e1..87fdcea0509 100644 --- a/actions/setup/js/messages_header.cjs +++ b/actions/setup/js/messages_header.cjs @@ -4,12 +4,12 @@ /** * Body Header Message Module * - * This module provides the body-header generation for safe-output workflows. + * This module provides the body-header and disclosure-header generation for safe-output workflows. * The body header is prepended to every message body generated by safe outputs * (issues, comments, pull requests, discussions). */ -const { getMessages, renderTemplate, toSnakeCase } = require("./messages_core.cjs"); +const { getMessages, getPromptPath, renderTemplate, renderTemplateFromFile, toSnakeCase } = require("./messages_core.cjs"); /** * @typedef {Object} BodyHeaderContext @@ -17,6 +17,11 @@ const { getMessages, renderTemplate, toSnakeCase } = require("./messages_core.cj * @property {string} runUrl - URL of the workflow run */ +const DEFAULT_DISCLOSURE_HEADER_TEMPLATE = "safe_outputs_disclosure_header.md"; +const DISCLOSURE_HEADER_DEFAULT_SENTINEL = "true"; + +const DEFAULT_DISCLOSURE_HEADER = renderTemplateFromFile(getPromptPath(DEFAULT_DISCLOSURE_HEADER_TEMPLATE), {}).trimEnd(); + /** * Get the body header text, using the custom template if configured. * Returns an empty string when no body-header is configured. @@ -33,6 +38,37 @@ function getBodyHeader(ctx) { return renderTemplate(messages.bodyHeader, templateContext); } +/** + * Get the AI authorship disclosure header text. + * When disclosure-header is "true", returns the built-in default AI disclosure text. + * When disclosure-header is a custom string, renders it as a template. + * Returns an empty string when disclosure-header is not configured. + * @param {BodyHeaderContext} ctx - Context for header generation + * @returns {string} The rendered disclosure header text, or empty string if not configured + */ +function getDisclosureHeader(ctx) { + const messages = getMessages(); + const disclosureHeader = messages?.disclosureHeader; + if (!disclosureHeader) { + return ""; + } + + let template = ""; + if (disclosureHeader === true || disclosureHeader === DISCLOSURE_HEADER_DEFAULT_SENTINEL) { + template = DEFAULT_DISCLOSURE_HEADER; + } else if (typeof disclosureHeader === "string") { + template = disclosureHeader; + } else { + return ""; + } + + const templateContext = toSnakeCase(ctx); + return renderTemplate(template, templateContext); +} + module.exports = { getBodyHeader, + getDisclosureHeader, + DEFAULT_DISCLOSURE_HEADER, + DISCLOSURE_HEADER_DEFAULT_SENTINEL, }; diff --git a/actions/setup/js/messages_header.test.cjs b/actions/setup/js/messages_header.test.cjs index f2dd5cf02fb..0a1b8ca56d2 100644 --- a/actions/setup/js/messages_header.test.cjs +++ b/actions/setup/js/messages_header.test.cjs @@ -1,5 +1,8 @@ // @ts-check -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; // messages_core.cjs calls core.warning on parse failures - provide a stub const mockCore = { @@ -11,12 +14,32 @@ const mockCore = { }; global.core = mockCore; -const { getBodyHeader } = require("./messages_header.cjs"); +const require = createRequire(import.meta.url); +let originalPromptsDir; +let getBodyHeader; +let getDisclosureHeader; +let DEFAULT_DISCLOSURE_HEADER; +let DISCLOSURE_HEADER_DEFAULT_SENTINEL; const WORKFLOW = "My Workflow"; const RUN_URL = "https://github.com/owner/repo/actions/runs/99"; describe("messages_header", () => { + beforeAll(() => { + originalPromptsDir = process.env.GH_AW_PROMPTS_DIR; + process.env.GH_AW_PROMPTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "../md"); + vi.resetModules(); + ({ getBodyHeader, getDisclosureHeader, DEFAULT_DISCLOSURE_HEADER, DISCLOSURE_HEADER_DEFAULT_SENTINEL } = require("./messages_header.cjs")); + }); + + afterAll(() => { + if (originalPromptsDir === undefined) { + delete process.env.GH_AW_PROMPTS_DIR; + } else { + process.env.GH_AW_PROMPTS_DIR = originalPromptsDir; + } + }); + beforeEach(() => { vi.clearAllMocks(); delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES; @@ -73,4 +96,66 @@ describe("messages_header", () => { expect(result).toBe(`> Header ${WORKFLOW} {unknown_placeholder}`); }); }); + + describe("getDisclosureHeader", () => { + it("returns empty string when disclosure-header is not configured", () => { + const result = getDisclosureHeader({ workflowName: WORKFLOW, runUrl: RUN_URL }); + expect(result).toBe(""); + }); + + it("returns rendered default text when disclosure-header is sentinel", () => { + process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ + disclosureHeader: DISCLOSURE_HEADER_DEFAULT_SENTINEL, + }); + + const result = getDisclosureHeader({ workflowName: WORKFLOW, runUrl: RUN_URL }); + expect(result).toContain(WORKFLOW); + expect(result).not.toContain(RUN_URL); + expect(result).not.toContain("🤖"); + }); + + it("returns rendered default text when disclosure-header is boolean true", () => { + process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ + disclosureHeader: true, + }); + + const result = getDisclosureHeader({ workflowName: WORKFLOW, runUrl: RUN_URL }); + expect(result).toContain(WORKFLOW); + expect(result).not.toContain(RUN_URL); + expect(result).not.toContain("🤖"); + }); + + it("returns rendered custom template when disclosure-header is a string", () => { + process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ + disclosureHeader: "> 🤖 Custom disclosure for [{workflow_name}]({run_url}).", + }); + + const result = getDisclosureHeader({ workflowName: WORKFLOW, runUrl: RUN_URL }); + expect(result).toBe(`> 🤖 Custom disclosure for [${WORKFLOW}](${RUN_URL}).`); + }); + + it("returns empty string when messages env is set but disclosure-header is absent", () => { + process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ + footer: "> Custom footer", + }); + + const result = getDisclosureHeader({ workflowName: WORKFLOW, runUrl: RUN_URL }); + expect(result).toBe(""); + }); + + it("returns empty string when disclosure-header is boolean false", () => { + process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ + disclosureHeader: false, + }); + + const result = getDisclosureHeader({ workflowName: WORKFLOW, runUrl: RUN_URL }); + expect(result).toBe(""); + }); + + it("DEFAULT_DISCLOSURE_HEADER contains expected placeholders", () => { + expect(DEFAULT_DISCLOSURE_HEADER).toContain("{workflow_name}"); + expect(DEFAULT_DISCLOSURE_HEADER).not.toContain("{run_url}"); + expect(DEFAULT_DISCLOSURE_HEADER).not.toContain("🤖"); + }); + }); }); diff --git a/actions/setup/md/safe_outputs_disclosure_header.md b/actions/setup/md/safe_outputs_disclosure_header.md new file mode 100644 index 00000000000..24509568df0 --- /dev/null +++ b/actions/setup/md/safe_outputs_disclosure_header.md @@ -0,0 +1 @@ +> **Automated content** generated by the {workflow_name} workflow. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 90b8c72f5a2..da379868dec 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -10498,6 +10498,20 @@ "description": "Custom header text prepended to every message body generated by safe outputs (issues, comments, pull requests, discussions). Applied after any threat-detection caution alert and before the agent-generated content. Available placeholders: {workflow_name}, {run_url}.", "examples": ["> ⚠️ This content was generated by [{workflow_name}]({run_url}).", "> 🤖 AI-generated output — please review before acting."] }, + "disclosure-header": { + "oneOf": [ + { + "type": "boolean", + "description": "Set to true to prepend the built-in AI authorship disclosure header to every message body (issues, comments, pull requests, discussions). Set to false to disable disclosure-header. The default text states the content was automatically generated and was not written or reviewed by the account owner personally." + }, + { + "type": "string", + "description": "Custom AI authorship disclosure header prepended to every message body. Available placeholders: {workflow_name}, {run_url}.", + "examples": ["> 🤖 This content was generated by [{workflow_name}]({run_url}) and was not written by the account owner."] + } + ], + "description": "AI authorship disclosure header prepended to every message body. Set to true for built-in default text, or provide a custom template string. Insertion order from top to bottom: threat-detection caution alert (if any) → disclosure-header → body-header → agent-generated content. Available placeholders: {workflow_name}, {run_url}." + }, "append-only-comments": { "type": "boolean", "description": "When enabled, workflow completion notifier creates a new comment instead of editing the activation comment. Creates an append-only timeline of workflow runs. Default: false", diff --git a/pkg/workflow/safe_outputs_config_types.go b/pkg/workflow/safe_outputs_config_types.go index 2c084932954..911cc814089 100644 --- a/pkg/workflow/safe_outputs_config_types.go +++ b/pkg/workflow/safe_outputs_config_types.go @@ -136,6 +136,7 @@ type SafeOutputMessagesConfig struct { AgentFailureIssue string `yaml:"agent-failure-issue,omitempty" json:"agentFailureIssue,omitempty"` // Custom footer template for agent failure tracking issues AgentFailureComment string `yaml:"agent-failure-comment,omitempty" json:"agentFailureComment,omitempty"` // Custom footer template for comments on agent failure tracking issues BodyHeader string `yaml:"body-header,omitempty" json:"bodyHeader,omitempty"` // Custom header text prepended to every message body (issues, comments, PRs, discussions). Placeholders: {workflow_name}, {run_url} + DisclosureHeader string `yaml:"disclosure-header,omitempty" json:"disclosureHeader,omitempty"` // AI authorship disclosure header prepended to every message body. Set to "true" for built-in default text, or provide a custom template string. Placeholders: {workflow_name}, {run_url} } // MentionsConfig holds configuration for @mention filtering in safe outputs diff --git a/pkg/workflow/safe_outputs_messages_config.go b/pkg/workflow/safe_outputs_messages_config.go index 0f52135fe14..d449ecb063b 100644 --- a/pkg/workflow/safe_outputs_messages_config.go +++ b/pkg/workflow/safe_outputs_messages_config.go @@ -9,6 +9,8 @@ import ( var safeOutputMessagesLog = logger.New("workflow:safe_outputs_config_messages") +const disclosureHeaderDefaultSentinel = "true" + // ======================================== // Safe Output Messages Configuration // ======================================== @@ -42,6 +44,18 @@ func parseMessagesConfig(messagesMap map[string]any) *SafeOutputMessagesConfig { config.AgentFailureComment = extractStringFromMap(messagesMap, "agent-failure-comment", nil) config.BodyHeader = extractStringFromMap(messagesMap, "body-header", nil) + // Handle disclosure-header: can be bool (true for default built-in text) or custom string + if dh, exists := messagesMap["disclosure-header"]; exists { + switch v := dh.(type) { + case bool: + if v { + config.DisclosureHeader = disclosureHeaderDefaultSentinel + } + case string: + config.DisclosureHeader = v + } + } + return config } diff --git a/pkg/workflow/safe_outputs_messages_test.go b/pkg/workflow/safe_outputs_messages_test.go index edba6c4f54f..f417c98969d 100644 --- a/pkg/workflow/safe_outputs_messages_test.go +++ b/pkg/workflow/safe_outputs_messages_test.go @@ -127,6 +127,81 @@ func TestSafeOutputsMessagesConfiguration(t *testing.T) { t.Errorf("Expected BodyHeader to be custom template, got %q", config.Messages.BodyHeader) } }) + + t.Run("Should parse disclosure-header as bool true", func(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "create-issue": nil, + "messages": map[string]any{ + "disclosure-header": true, + }, + }, + } + + config := compiler.extractSafeOutputsConfig(frontmatter) + if config == nil { + t.Fatal("Expected SafeOutputsConfig to be parsed") + } + + if config.Messages == nil { + t.Fatal("Expected Messages to be parsed") + } + + if config.Messages.DisclosureHeader != disclosureHeaderDefaultSentinel { + t.Errorf("Expected DisclosureHeader to be %q when bool true is set, got %q", disclosureHeaderDefaultSentinel, config.Messages.DisclosureHeader) + } + }) + + t.Run("Should parse disclosure-header as custom string", func(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "create-issue": nil, + "messages": map[string]any{ + "disclosure-header": "> 🤖 Custom disclosure for [{workflow_name}]({run_url}).", + }, + }, + } + + config := compiler.extractSafeOutputsConfig(frontmatter) + if config == nil { + t.Fatal("Expected SafeOutputsConfig to be parsed") + } + + if config.Messages == nil { + t.Fatal("Expected Messages to be parsed") + } + + if config.Messages.DisclosureHeader != "> 🤖 Custom disclosure for [{workflow_name}]({run_url})." { + t.Errorf("Expected DisclosureHeader to be custom template, got %q", config.Messages.DisclosureHeader) + } + }) + + t.Run("Should not set disclosure-header when bool false", func(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "create-issue": nil, + "messages": map[string]any{ + "disclosure-header": false, + }, + }, + } + + config := compiler.extractSafeOutputsConfig(frontmatter) + if config == nil { + t.Fatal("Expected SafeOutputsConfig to be parsed") + } + + if config.Messages == nil { + t.Fatal("Expected Messages to be parsed") + } + + if config.Messages.DisclosureHeader != "" { + t.Errorf("Expected DisclosureHeader to be empty when bool false is set, got %q", config.Messages.DisclosureHeader) + } + }) } func TestSerializeMessagesConfig(t *testing.T) { @@ -245,4 +320,32 @@ func TestSerializeMessagesConfig(t *testing.T) { t.Errorf("Expected JSON to NOT contain PascalCase key 'BodyHeader', got: %s", result) } }) + + t.Run("Should serialize disclosure-header to camelCase JSON key", func(t *testing.T) { + config := &SafeOutputMessagesConfig{ + DisclosureHeader: disclosureHeaderDefaultSentinel, + } + + result, err := serializeMessagesConfig(config) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var parsed SafeOutputMessagesConfig + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("Result is not valid JSON: %v", err) + } + + if parsed.DisclosureHeader != disclosureHeaderDefaultSentinel { + t.Errorf("Expected DisclosureHeader to be preserved, got %q", parsed.DisclosureHeader) + } + + if !strings.Contains(result, `"disclosureHeader"`) { + t.Errorf("Expected JSON to contain camelCase key 'disclosureHeader', got: %s", result) + } + + if strings.Contains(result, `"DisclosureHeader"`) { + t.Errorf("Expected JSON to NOT contain PascalCase key 'DisclosureHeader', got: %s", result) + } + }) }