Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
692dc75
Track agent provenance and support coarse issue-body redaction
pelikhan Oct 18, 2026
eddf4aa
Harden issue redaction provenance and diagnostic boundaries
pelikhan Oct 18, 2026
125e83e
Fix inline redaction and safe-output dependencies
Copilot Oct 19, 2026
9e2c918
Redact secrets across inline style boundaries
Copilot Oct 19, 2026
418d243
Redact credentials across formatting containers
Copilot Oct 19, 2026
a152576
Preserve unrelated inline formatting on redaction
Copilot Oct 19, 2026
090a72e
Cover nested secret with unrelated inline formatting
Copilot Oct 19, 2026
62e7506
Merge remote-tracking branch 'origin/main' into pelikhan-fluffy-fortn…
Copilot Oct 19, 2026
92af45d
Merge remote-tracking branch 'origin/main' into pelikhan-fluffy-fortn…
pelikhan Oct 19, 2026
2f6b1db
Clarify redaction provenance JSDoc
Copilot Oct 19, 2026
6af3257
Clarify redaction contracts and extend review regressions
pelikhan Oct 19, 2026
401b7f0
Integrate concurrent redaction documentation fixes
pelikhan Oct 19, 2026
6b721f1
Assert report trees are redacted before concretization
pelikhan Oct 19, 2026
078d1f8
Merge remote-tracking branch 'origin/main' into pelikhan-fluffy-fortn…
Copilot Oct 19, 2026
11171d2
Fit report bodies with provenance-aware size redaction
pelikhan Oct 19, 2026
01f3c58
Promote redacted agent details containers
Copilot Oct 19, 2026
5e9d82c
Merge remote-tracking branch 'origin/pelikhan-fluffy-fortnight' into …
Copilot Oct 19, 2026
b6857bd
Align custom issue size-limit tests with safe body fitting
pelikhan Oct 19, 2026
a450676
Add adversarial report redaction and size-fitting tests
pelikhan Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/patch-issue-redaction-provenance.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 22 additions & 14 deletions actions/setup/js/add_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ const { getMissingInfoSections } = require("./missing_messages_helper.cjs");
const { getMessages } = require("./messages_core.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 { MAX_COMMENT_LENGTH, MAX_COMMENT_INPUT_LENGTH, MAX_MENTIONS, MAX_LINKS, enforceCommentLimits } = require("./comment_limit_helpers.cjs");
const { fitMarkdownBody } = require("./report_size.cjs");
const { createDiscussionComment, resolveTopLevelDiscussionCommentId } = require("./github_api_helpers.cjs");
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
const { ERR_CONFIG, ERR_NOT_FOUND } = require("./error_codes.cjs");
Expand Down Expand Up @@ -812,13 +813,12 @@ async function main(config = {}) {
// Replace temporary ID references in body
let processedBody = replaceTemporaryIdReferences(message.body || "", temporaryIdMap, itemRepo);

// Sanitize content to prevent injection attacks, allowing parent issue/PR/discussion authors
// so they can be @mentioned in the generated comment.
processedBody = sanitizeContent(processedBody, { allowedAliases: allowedMentionAliases, maxMentions });

// Enforce max limits before processing (validates user-provided content)
try {
enforceCommentLimits(processedBody, { maxLinks });
if (processedBody.length > MAX_COMMENT_INPUT_LENGTH) enforceCommentLimits(processedBody, { maxLinks, maxLength: MAX_COMMENT_INPUT_LENGTH });
// Allow parent authors while sanitizing content, before measuring publication size.
processedBody = sanitizeContent(processedBody, { allowedAliases: allowedMentionAliases, maxMentions });
enforceCommentLimits(processedBody, { maxLinks, maxLength: MAX_COMMENT_INPUT_LENGTH });
} catch (error) {
const errorMessage = getErrorMessage(error);
core.warning(`Comment validation failed: ${errorMessage}`);
Expand Down Expand Up @@ -847,12 +847,13 @@ async function main(config = {}) {

// 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;
const bodyPrefix = prefixParts.length > 0 ? prefixParts.join("\n\n") + "\n\n" : "";
let bodySuffix = "";

// Add tracker ID and footer
const trackerIDComment = getTrackerID("markdown");
if (trackerIDComment) {
processedBody += "\n\n" + trackerIDComment;
bodySuffix += "\n\n" + trackerIDComment;
}

// Get triggering context for footer
Expand Down Expand Up @@ -886,27 +887,34 @@ async function main(config = {}) {

if (includeFooter) {
// When footer is enabled, add full footer with attribution and XML markers.
processedBody += "\n\n" + markdownParts.footer;
bodySuffix += "\n\n" + markdownParts.footer;
} else {
// When footer is disabled, only add XML marker for searchability (no visible attribution text)
processedBody += "\n\n" + markdownParts.noFooterMarker;
bodySuffix += "\n\n" + markdownParts.noFooterMarker;
}
const bodyFooter = getBodyFooterMessage(config.body_footer, { workflowName, runUrl });
if (bodyFooter) {
processedBody += "\n\n" + bodyFooter.trimEnd();
bodySuffix += "\n\n" + bodyFooter.trimEnd();
}

// Add workflow-call-id marker when available to allow close-older-comments to
// distinguish callers that share the same reusable workflow (and GH_AW_WORKFLOW_ID)
if (callerWorkflowId) {
processedBody += "\n" + generateWorkflowCallIdMarker(callerWorkflowId);
bodySuffix += "\n" + generateWorkflowCallIdMarker(callerWorkflowId);
}

processedBody = appendExecutionProvenance(processedBody, workflowName, runUrl);

const framedBody = bodyPrefix + linkBody + bodySuffix;
processedBody = appendExecutionProvenance(framedBody, workflowName, runUrl);
// Enforce max limits again after adding footer and metadata
// Length and mention limits include metadata; only agent content counts toward the link limit.
try {
if (processedBody.length > MAX_COMMENT_LENGTH) {
// Provenance replaces reserved markers; reserve its actual net overhead, not duplicate markers.
const fittingLimit = MAX_COMMENT_LENGTH + framedBody.length - processedBody.length;
if (fittingLimit < 1) throw new Error(`E006: Protected execution metadata exceeds the maximum body length of ${MAX_COMMENT_LENGTH} characters`);
processedBody = fitMarkdownBody(linkBody, { prefix: bodyPrefix, suffix: bodySuffix, maxLength: fittingLimit, allowedMentions: allowedMentionAliases });
processedBody = appendExecutionProvenance(processedBody, workflowName, runUrl);
}
enforceCommentLimits(processedBody, { maxLinks, linkBody });
} catch (error) {
const errorMessage = getErrorMessage(error);
Expand Down
80 changes: 80 additions & 0 deletions actions/setup/js/add_comment.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,86 @@ describe("add_comment", () => {
delete process.env.GH_AW_COMMENT_ID;
});

describe("publication size fitting", () => {
it("rejects over-limit input before sanitization could truncate its trailing protected marker", async () => {
const script = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");
const createComment = vi.fn(mockGithub.rest.issues.createComment);
mockGithub.rest.issues.createComment = createComment;
const handler = await eval(`(async () => { ${script}; return await main({}); })()`);
const result = await handler({ type: "add_comment", body: "x".repeat(524289) + "\n<!-- custom: KEEP-THIS-ENTIRE-MARKER -->" }, {});
expect(result.success).toBe(false);
expect(result.error).toMatch(/E006.*524288/);
expect(createComment).not.toHaveBeenCalled();
});

it.each([true, false])("keeps text intact when the final provenance-inclusive body exactly fits with footer=%s", async footer => {
const script = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");
const handler = await eval(`(async () => { ${script}; return await main(${JSON.stringify({ max: 2, footer })}); })()`);
const baseline = await handler({ type: "add_comment", body: "x" }, {});
const agentBody = "x".repeat(65536 - (baseline.body.length - 1));
const result = await handler({ type: "add_comment", body: agentBody }, {});
expect(result.success).toBe(true);
expect(result.body).toHaveLength(65536);
expect(result.body).toContain(agentBody);
expect(result.body).not.toContain("Content redacted to fit size limit.");
expectExecutionProvenance(result.body);
});

it("does not double-count replaced provenance when a large protected footer leaves only notice space", async () => {
const script = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");
const baselineHandler = await eval(`(async () => { ${script}; return await main({ footer: false, body_footer: "Policy" }); })()`);
const baseline = await baselineHandler({ type: "add_comment", body: "x" }, {});
const notice = "Content redacted to fit size limit.";
const policy = "f".repeat(65536 - (baseline.body.length - 1 - "Policy".length) - notice.length);
const handler = await eval(`(async () => { ${script}; return await main(${JSON.stringify({ footer: false, body_footer: policy })}); })()`);
const result = await handler({ type: "add_comment", body: "x".repeat(100000) }, {});
expect(result.success).toBe(true);
expect(result.body).toHaveLength(65536);
expect(result.body).toContain(notice);
expect(result.body).toContain(policy);
expectExecutionProvenance(result.body);
});

it.each([true, false])("fits large content and preserves full configured footer and provenance with footer=%s", async footer => {
const script = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");
const policy = "POLICY-START " + "f".repeat(1000) + " POLICY-END";
const createComment = vi.fn(mockGithub.rest.issues.createComment);
mockGithub.rest.issues.createComment = createComment;
const handler = await eval(`(async () => { ${script}; return await main(${JSON.stringify({ footer, body_footer: policy })}); })()`);
const result = await handler({ type: "add_comment", body: "x".repeat(100000) }, {});
expect(result.success).toBe(true);
expect(createComment).toHaveBeenCalledOnce();
const body = createComment.mock.calls[0][0].body;
expect(body.length).toBeLessThanOrEqual(65536);
expect(body).toContain("Content redacted to fit size limit.");
expect(body).toContain(policy);
expectExecutionProvenance(body);
if (footer) expect(body).toContain("> Generated by");
});

it("rejects protected-footer overflow without attempting an API write", async () => {
const script = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");
const createComment = vi.fn(mockGithub.rest.issues.createComment);
mockGithub.rest.issues.createComment = createComment;
const handler = await eval(`(async () => { ${script}; return await main({ body_footer: "f".repeat(65536) }); })()`);
const result = await handler({ type: "add_comment", body: "Normal body" }, {});
expect(result.success).toBe(false);
expect(result.error).toContain("Protected markers and footers");
expect(createComment).not.toHaveBeenCalled();
});

it("still enforces the original link budget before reducing large text", async () => {
const script = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");
const createComment = vi.fn(mockGithub.rest.issues.createComment);
mockGithub.rest.issues.createComment = createComment;
const handler = await eval(`(async () => { ${script}; return await main({ max_links: 1 }); })()`);
const result = await handler({ type: "add_comment", body: "x".repeat(100000) + " https://github.com/owner/repo/issues/1 https://github.com/owner/repo/issues/2" }, {});
expect(result.success).toBe(false);
expect(result.error).toContain("E008");
expect(createComment).not.toHaveBeenCalled();
});
});

describe("link limits", () => {
it.each([
[{}, 50, true],
Expand Down
15 changes: 6 additions & 9 deletions actions/setup/js/body_footer.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,28 @@

const { getBodyFooterMessage } = require("./messages_footer.cjs");
const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
const { fitMarkdownBody } = require("./report_size.cjs");

/**
* Append a configured deterministic body footer using the current workflow context.
* When `maxLength` is provided and appending the footer would exceed that limit,
* the original body is returned unchanged so the request still satisfies the
* destination's body contract.
* When `maxLength` is provided, fit agent content to the remaining budget without
* shortening or omitting the configured footer.
* @param {string} body
* @param {string|undefined} template
* @param {{workflowRepo?: any, maxLength?: number}} [options]
* @returns {string}
*/
function appendConfiguredBodyFooter(body, template, options = {}) {
if (!template) return body;
const { workflowRepo, maxLength } = options || {};
if (!template) return maxLength === undefined ? body : fitMarkdownBody(body, { maxLength });
const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
const runUrl = buildWorkflowRunUrl(context, workflowRepo || context.repo);
const bodyFooter = getBodyFooterMessage(template, { workflowName, runUrl });
if (!bodyFooter) return body;
if (!bodyFooter) return maxLength === undefined ? body : fitMarkdownBody(body, { maxLength });
const trimmedBody = body.trimEnd();
const composed = trimmedBody ? `${trimmedBody}\n\n${bodyFooter.trimEnd()}` : bodyFooter.trimEnd();
if (typeof maxLength === "number" && maxLength > 0 && composed.length > maxLength) {
if (typeof core !== "undefined" && core && typeof core.warning === "function") {
core.warning(`Configured body footer omitted: appending it would exceed the ${maxLength} character body limit`);
}
return body;
return fitMarkdownBody(trimmedBody, { suffix: trimmedBody ? `\n\n${bodyFooter.trimEnd()}` : bodyFooter.trimEnd(), maxLength });
}
return composed;
}
Expand Down
8 changes: 6 additions & 2 deletions actions/setup/js/body_footer.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,13 @@ describe("appendConfiguredBodyFooter", () => {
expect(appendConfiguredBodyFooter("Body", undefined)).toBe("Body");
});

it("omits the footer when appending would exceed the destination limit", () => {
it("redacts oversized body text without shortening or omitting the footer", () => {
const body = "x".repeat(100);
expect(appendConfiguredBodyFooter(body, "Footer", { maxLength: 100 })).toBe(body);
expect(appendConfiguredBodyFooter(body, "Footer", { maxLength: 100 })).toBe("Content redacted to fit size limit.\n\nFooter");
});

it("fails explicitly when the footer alone cannot fit", () => {
expect(() => appendConfiguredBodyFooter("Body", "f".repeat(101), { maxLength: 100 })).toThrow("Protected markers and footers");
});

it("appends the footer when the composed body fits the destination limit", () => {
Expand Down
10 changes: 6 additions & 4 deletions actions/setup/js/comment_limit_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

/** @type {number} Maximum comment body length (GitHub's limit) */
const MAX_COMMENT_LENGTH = 65536;
const MAX_COMMENT_INPUT_LENGTH = 524288;

/** @type {number} Maximum number of mentions allowed per comment */
const MAX_MENTIONS = 10;
Expand Down Expand Up @@ -57,13 +58,13 @@ function countMentionsOutsideCodeRegions(body) {
* Per Safe Outputs specification requirement MR3, limits must be enforced before API calls.
*
* @param {string} body - Comment body to validate
* @param {{ maxLinks?: number, linkBody?: string }} [options] - Link limit and agent content, excluding trusted generated metadata
* @param {{ maxLinks?: number, linkBody?: string, maxLength?: number }} [options] - Limits and agent content, excluding trusted generated metadata
* @throws {Error} When any limit is exceeded, with error code and details
*/
function enforceCommentLimits(body, { maxLinks = MAX_LINKS, linkBody = body } = {}) {
function enforceCommentLimits(body, { maxLinks = MAX_LINKS, linkBody = body, maxLength = MAX_COMMENT_LENGTH } = {}) {
// Check body length - max limit exceeded check
if (body.length > MAX_COMMENT_LENGTH) {
throw new Error(`E006: Comment body exceeds maximum length of ${MAX_COMMENT_LENGTH} characters (got ${body.length}). Reduce the comment body to ${MAX_COMMENT_LENGTH} characters or fewer.`);
if (body.length > maxLength) {
throw new Error(`E006: Comment body exceeds maximum length of ${maxLength} characters (got ${body.length}). Reduce the comment body to ${maxLength} characters or fewer.`);
}

// Count mentions (@username pattern) - max limit exceeded check
Expand All @@ -81,6 +82,7 @@ function enforceCommentLimits(body, { maxLinks = MAX_LINKS, linkBody = body } =

module.exports = {
MAX_COMMENT_LENGTH,
MAX_COMMENT_INPUT_LENGTH,
MAX_MENTIONS,
MAX_LINKS,
enforceCommentLimits,
Expand Down
19 changes: 17 additions & 2 deletions actions/setup/js/create_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1140,14 +1140,29 @@ describe("create_issue", () => {
});

describe("error handling", () => {
it.each([false, true])("should reject an oversized rendered body before reserving a max-count slot with staged=%s", async staged => {
it.each([false, true])("fits oversized agent text for publication with staged=%s", async staged => {
const handler = await main({ max: 1, staged });
const result = await handler({ title: "Oversized issue", body: "x".repeat(100000) });
expect(result.success).toBe(true);
if (staged) expect(result.previewInfo.bodyLength).toBeLessThanOrEqual(65536);
else {
expect(result.body.length).toBeLessThanOrEqual(65536);
expect(result.body).toContain("Content redacted to fit size limit.");
expect(result.body).toContain("<!-- gh-aw-execution:");
}
expect(mockGithub.rest.issues.create).toHaveBeenCalledTimes(staged ? 0 : 1);
});

it.each([false, true])("rejects oversized protected footers before reserving a max-count slot with staged=%s", async staged => {
const handler = await main({ max: 1, staged });

await expect(handler({ title: "Oversized issue", body: "x".repeat(65536) })).rejects.toThrow("ERR_VALIDATION: Issue body exceeds GitHub's maximum length of 65536 characters including attribution");
vi.stubEnv("GH_AW_SAFE_OUTPUT_MESSAGES", JSON.stringify({ footer: "f".repeat(65536) }));
await expect(handler({ title: "Oversized issue", body: "Normal body" })).rejects.toThrow("protected markers, footers, and framework content cannot be reduced");

expect(mockGithub.rest.issues.create).not.toHaveBeenCalled();
expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled();

vi.stubEnv("GH_AW_SAFE_OUTPUT_MESSAGES", "");
const result = await handler({ title: "Valid issue", body: "Issue content" });
expect(result.success).toBe(true);
expect(mockGithub.rest.issues.create).toHaveBeenCalledTimes(staged ? 0 : 1);
Expand Down
6 changes: 3 additions & 3 deletions actions/setup/js/create_pr_review_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const { resolveDefaultMentions, resolveMentionsForItem } = require("./resolve_me
const { parseIntTemplatable } = require("./templatable.cjs");
const { resolveInvocationContext } = require("./invocation_context_helpers.cjs");
const { ERR_VALIDATION } = require("./error_codes.cjs");
const { fitMarkdownBody } = require("./report_size.cjs");

/** @type {string} Safe output type handled by this module */
const HANDLER_TYPE = "create_pull_request_review_comment";
Expand Down Expand Up @@ -379,14 +380,13 @@ async function main(config = {}) {
// Buffer the comment instead of posting it individually
let body = sanitizeContent(commentItem.body.trim(), { allowedAliases: allowedMentionAliases, maxMentions });
const bodyFooter = getBodyFooterMessage(config.body_footer, { workflowName, runUrl });
if (bodyFooter) {
body += "\n\n" + bodyFooter.trimEnd();
}
body = fitMarkdownBody(body, { suffix: bodyFooter ? "\n\n" + bodyFooter.trimEnd() : "", allowedMentions: allowedMentionAliases });
/** @type {import('./pr_review_buffer.cjs').BufferedComment} */
const bufferedComment = {
path: commentItem.path,
line: line,
body,
...(bodyFooter ? { bodyFooter: "\n\n" + bodyFooter.trimEnd() } : {}),
side: side,
};

Expand Down
Loading
Loading