From 6b9c6a0f8ab824001695c0064cd299cb2b8dd675 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:11:45 +0000 Subject: [PATCH 1/5] Initial plan From cb0a3faa485da7379d3820ec27ae61e73e67823c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:19:39 +0000 Subject: [PATCH 2/5] Fail rejected issue-intent closes instead of bypassing review Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/close_issue.cjs | 4 +++ actions/setup/js/close_issue.test.cjs | 35 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/actions/setup/js/close_issue.cjs b/actions/setup/js/close_issue.cjs index 2092e35d10d..97dc17084a1 100644 --- a/actions/setup/js/close_issue.cjs +++ b/actions/setup/js/close_issue.cjs @@ -180,6 +180,10 @@ async function closeIssue(github, owner, repo, issueNumber, stateReason, intentM }); return issue; } catch (error) { + const status = error && typeof error === "object" && "status" in error ? error.status : undefined; + if (status !== 404 && status !== 501) { + throw error; + } core.warning(`Issue-intent close path unavailable, falling back to legacy close path: ${getErrorMessage(error)}`); } } diff --git a/actions/setup/js/close_issue.test.cjs b/actions/setup/js/close_issue.test.cjs index 6279d55a33d..e40df2debcc 100644 --- a/actions/setup/js/close_issue.test.cjs +++ b/actions/setup/js/close_issue.test.cjs @@ -175,6 +175,41 @@ describe("close_issue", () => { expect(requestCalls[0].params.headers).toBeUndefined(); }); + it.each([403, 422, 500])("should fail without a legacy close when the intent request returns %i", async status => { + const handler = await main({ max: 10 }); + let updateCalled = false; + mockGithub.request = async () => { + throw Object.assign(new Error("Intent rejected"), { status }); + }; + mockGithub.rest.issues.update = async () => { + updateCalled = true; + throw new Error("Legacy close must not be called"); + }; + + const result = await handler({ issue_number: 456, body: "Closing this issue", rationale: "Confirmed" }, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("Intent rejected"); + expect(updateCalled).toBe(false); + }); + + it.each([404, 501])("should fall back to a legacy close when the intent endpoint returns %i", async status => { + const handler = await main({ max: 10 }); + let updateCalled = false; + mockGithub.request = async () => { + throw Object.assign(new Error("Intent endpoint unavailable"), { status }); + }; + mockGithub.rest.issues.update = async params => { + updateCalled = true; + return { data: { number: params.issue_number, title: "Test Issue", html_url: "https://github.com/test-owner/test-repo/issues/456" } }; + }; + + const result = await handler({ issue_number: 456, body: "Closing this issue", rationale: "Confirmed" }, {}); + + expect(result.success).toBe(true); + expect(updateCalled).toBe(true); + }); + it("should skip issue-intent metadata when explicitly disabled", async () => { const handler = await main({ max: 10, issue_intent: false }); const updateCalls = []; From 6f580df798e437a74a17e8a2f6d16d185c612ea0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:38:46 +0000 Subject: [PATCH 3/5] Document issue-intent close fallback boundary Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../docs/specs/safe-outputs-specification.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/specs/safe-outputs-specification.md b/docs/src/content/docs/specs/safe-outputs-specification.md index aad25378247..3d44bb8e0b7 100644 --- a/docs/src/content/docs/specs/safe-outputs-specification.md +++ b/docs/src/content/docs/specs/safe-outputs-specification.md @@ -7,9 +7,9 @@ sidebar: # Safe Outputs MCP Gateway Specification -**Version**: 1.29.7
+**Version**: 1.29.8
**Status**: Working Draft
-**Publication Date**: 2026-09-23
+**Publication Date**: 2026-10-01
**Editor**: GitHub Agentic Workflows Team
**This Version**: [safe-outputs-specification](/gh-aw/specs/safe-outputs-specification/)
**Latest Published Version**: This document @@ -2937,7 +2937,8 @@ For all Linear types, GraphQL source, endpoint, protocol, and host are implement 4. **Cross-Repository**: When `target-repo` is configured, operates on that repository (must be in `allowed-repos`). 5. **Footer Injection**: Appends attribution footer to the closing comment when configured. 6. **Body Suppression**: When `allow-body` is `false`, the handler MUST NOT post a closing comment. Any `body` value provided by the agent SHALL be discarded before the close operation is executed. The implementation MUST log a warning if a non-empty `body` value was discarded. -7. **Native Duplicate Marking**: When `duplicate_of` is provided and `state_reason` is `duplicate`, the handler SHALL call the GitHub `markAsDuplicate` GraphQL mutation to create a native "marked this as a duplicate of #X" timeline event. If the mutation fails (e.g., missing permissions or invalid reference), a warning is logged and the close operation continues. The `duplicate_of` value MUST be parsed and resolved to a valid issue node ID before calling the mutation. +7. **Issue-Intent Close**: When issue-intent metadata is present, the handler MUST submit the close through the issue-intent endpoint. If that request is rejected or otherwise fails, the safe-output item MUST fail and the handler MUST NOT retry through the legacy close endpoint, except for HTTP 404 or 501 responses, which MAY use the legacy endpoint as an availability fallback. +8. **Native Duplicate Marking**: When `duplicate_of` is provided and `state_reason` is `duplicate`, the handler SHALL call the GitHub `markAsDuplicate` GraphQL mutation to create a native "marked this as a duplicate of #X" timeline event. If the mutation fails (e.g., missing permissions or invalid reference), a warning is logged and the close operation continues. The `duplicate_of` value MUST be parsed and resolved to a valid issue node ID before calling the mutation. **Configuration Parameters**: @@ -2969,6 +2970,10 @@ For all Linear types, GraphQL source, endpoint, protocol, and host are implement **CI-005**: Schema shaping, prompt instructions, and temporary-ID resolution MUST NOT replace or precede runtime target authorization. Agent-supplied target identifiers, including unresolved temporary IDs, MUST be ignored unless `target` is `"*"`. +**CI-006**: When issue-intent metadata is present, a non-404/non-501 failure from the issue-intent close request MUST fail the safe-output item and MUST NOT invoke the legacy close endpoint. + +**CI-007**: The handler MAY invoke the legacy close endpoint only when the issue-intent close request returns HTTP 404 or 501, indicating that the intent endpoint may be unavailable. + **Required Permissions**: *GitHub Actions Token*: @@ -6052,6 +6057,11 @@ This specification revision aligns with directly relevant `CHANGELOG.md` entries - **Earlier changelog entry**: status comments were decoupled from default AI reaction behavior; explicit `on.status-comment` configuration is required when status comments are desired. - **Earlier changelog entry**: `command` trigger was renamed to `slash_command` with deprecation compatibility. +**Version 1.29.8** (2026-10-01): + +- **Specified**: Issue-intent close failures MUST fail closed rather than bypassing review through the legacy close endpoint; only HTTP 404 and 501 responses MAY use the legacy endpoint as an availability fallback. +- **Updated**: Publication metadata to 1.29.8. + **Version 1.29.7** (2026-09-29): - **Added**: Threat T8 "Private-to-Public Data Exposure" to Section 3.2, covering public Actions logs and public safe-output destinations. From 4841a79dec90e48d3d4524c7c8e8540612118359 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:13:36 +0000 Subject: [PATCH 4/5] Handle wrapped issue-intent HTTP errors Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/close_issue.cjs | 4 +++- actions/setup/js/close_issue.test.cjs | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/close_issue.cjs b/actions/setup/js/close_issue.cjs index 97dc17084a1..68710fd7904 100644 --- a/actions/setup/js/close_issue.cjs +++ b/actions/setup/js/close_issue.cjs @@ -180,7 +180,9 @@ async function closeIssue(github, owner, repo, issueNumber, stateReason, intentM }); return issue; } catch (error) { - const status = error && typeof error === "object" && "status" in error ? error.status : undefined; + const errorRecord = error && typeof error === "object" ? /** @type {Record} */ error : undefined; + const response = errorRecord?.response; + const status = response && typeof response === "object" && "status" in response ? response.status : errorRecord?.status; if (status !== 404 && status !== 501) { throw error; } diff --git a/actions/setup/js/close_issue.test.cjs b/actions/setup/js/close_issue.test.cjs index e40df2debcc..faeca9af178 100644 --- a/actions/setup/js/close_issue.test.cjs +++ b/actions/setup/js/close_issue.test.cjs @@ -210,6 +210,23 @@ describe("close_issue", () => { expect(updateCalled).toBe(true); }); + it("should fall back when the unavailable intent status is wrapped in the response", async () => { + const handler = await main({ max: 10 }); + let updateCalled = false; + mockGithub.request = async () => { + throw Object.assign(new Error("Intent endpoint unavailable"), { response: { status: 404 } }); + }; + mockGithub.rest.issues.update = async params => { + updateCalled = true; + return { data: { number: params.issue_number, title: "Test Issue", html_url: "https://github.com/test-owner/test-repo/issues/456" } }; + }; + + const result = await handler({ issue_number: 456, body: "Closing this issue", rationale: "Confirmed" }, {}); + + expect(result.success).toBe(true); + expect(updateCalled).toBe(true); + }); + it("should skip issue-intent metadata when explicitly disabled", async () => { const handler = await main({ max: 10, issue_intent: false }); const updateCalls = []; From f183092cd2a069661ca902eb87dc0d77b0ffb166 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:48:21 +0000 Subject: [PATCH 5/5] Add privacy-safe issue intent logging Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/close_issue.cjs | 6 +++++- actions/setup/js/close_issue.test.cjs | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/close_issue.cjs b/actions/setup/js/close_issue.cjs index 68710fd7904..435b2ae70a5 100644 --- a/actions/setup/js/close_issue.cjs +++ b/actions/setup/js/close_issue.cjs @@ -171,6 +171,7 @@ async function closeIssue(github, owner, repo, issueNumber, stateReason, intentM const hasIntentMetadata = Boolean(intentMetadata && Object.keys(intentMetadata).length > 0); if (useIssueIntent && hasIntentMetadata) { try { + core.debug("Attempting issue-intent close request"); const { data: issue } = await github.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", { owner, repo, @@ -178,15 +179,18 @@ async function closeIssue(github, owner, repo, issueNumber, stateReason, intentM state: { value: "closed", ...intentMetadata }, state_reason: baseParams.state_reason, }); + core.debug("Issue-intent close request succeeded"); return issue; } catch (error) { const errorRecord = error && typeof error === "object" ? /** @type {Record} */ error : undefined; const response = errorRecord?.response; const status = response && typeof response === "object" && "status" in response ? response.status : errorRecord?.status; + const statusForLog = typeof status === "number" && Number.isInteger(status) ? status : "unknown"; if (status !== 404 && status !== 501) { + core.debug(`Issue-intent close request failed; legacy fallback disabled (status=${statusForLog})`); throw error; } - core.warning(`Issue-intent close path unavailable, falling back to legacy close path: ${getErrorMessage(error)}`); + core.warning(`Issue-intent close endpoint unavailable (status=${statusForLog}); falling back to legacy close path`); } } diff --git a/actions/setup/js/close_issue.test.cjs b/actions/setup/js/close_issue.test.cjs index faeca9af178..6a92032069b 100644 --- a/actions/setup/js/close_issue.test.cjs +++ b/actions/setup/js/close_issue.test.cjs @@ -15,6 +15,7 @@ describe("close_issue", () => { warning: () => {}, error: () => {}, debug: () => {}, + debugs: [], messages: [], infos: [], warnings: [], @@ -34,6 +35,10 @@ describe("close_issue", () => { mockCore.errors.push(msg); mockCore.messages.push({ level: "error", message: msg }); }; + mockCore.debug = msg => { + mockCore.debugs.push(msg); + mockCore.messages.push({ level: "debug", message: msg }); + }; mockGithub = { rest: { @@ -173,6 +178,8 @@ describe("close_issue", () => { expect(requestCalls[0].params.rationale).toBeUndefined(); expect(requestCalls[0].params.confidence).toBeUndefined(); expect(requestCalls[0].params.headers).toBeUndefined(); + expect(mockCore.debugs).toContain("Attempting issue-intent close request"); + expect(mockCore.debugs).toContain("Issue-intent close request succeeded"); }); it.each([403, 422, 500])("should fail without a legacy close when the intent request returns %i", async status => { @@ -191,6 +198,8 @@ describe("close_issue", () => { expect(result.success).toBe(false); expect(result.error).toContain("Intent rejected"); expect(updateCalled).toBe(false); + expect(mockCore.debugs).toContain(`Issue-intent close request failed; legacy fallback disabled (status=${status})`); + expect(mockCore.debugs.join(" ")).not.toContain("Intent rejected"); }); it.each([404, 501])("should fall back to a legacy close when the intent endpoint returns %i", async status => { @@ -208,6 +217,7 @@ describe("close_issue", () => { expect(result.success).toBe(true); expect(updateCalled).toBe(true); + expect(mockCore.warnings).toEqual([`Issue-intent close endpoint unavailable (status=${status}); falling back to legacy close path`]); }); it("should fall back when the unavailable intent status is wrapped in the response", async () => { @@ -225,6 +235,7 @@ describe("close_issue", () => { expect(result.success).toBe(true); expect(updateCalled).toBe(true); + expect(mockCore.warnings).toEqual(["Issue-intent close endpoint unavailable (status=404); falling back to legacy close path"]); }); it("should skip issue-intent metadata when explicitly disabled", async () => {