From 65dbc3e7ff3950bee9cee66d7981d0243ea87cb4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:11:45 +0000 Subject: [PATCH 1/7] Enforce add-labels target configuration Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/add_labels.cjs | 19 +++-- actions/setup/js/add_labels.test.cjs | 101 +++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/actions/setup/js/add_labels.cjs b/actions/setup/js/add_labels.cjs index 37ab983e882..b4d15b3e266 100644 --- a/actions/setup/js/add_labels.cjs +++ b/actions/setup/js/add_labels.cjs @@ -212,6 +212,7 @@ const main = createCountGatedHandler({ handlerType: HANDLER_TYPE, setup: async (config, maxCount, isStaged) => { const { allowed: allowedLabels = [], blocked: blockedPatterns = [] } = config; + const target = config.target || "triggering"; const issueIntentEnabled = config.issue_intent !== false; const issueIntentStrict = config.issue_intent === true; // strict mode: plain-string labels rejected, metadata required const createIfMissing = config.create_if_missing === true; @@ -248,12 +249,20 @@ const main = createCountGatedHandler({ const { repo: itemRepo, repoParts } = repoResult; core.info(`Target repository: ${itemRepo}`); - // Determine target issue/PR number - // Accept common aliases: issue_number, pr_number, and pull_number are normalised to item_number - const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE }); - if (!targetResult.success) return targetResult; const effectiveContext = resolveInvocationContext(context); - const itemNumber = targetResult.number ?? effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number; + const triggeringItemNumber = effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number; + let itemNumber; + + if (target === "*") { + // Accept common aliases: issue_number, pr_number, and pull_number are normalised to item_number + const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE }); + if (!targetResult.success) return targetResult; + itemNumber = targetResult.number ?? triggeringItemNumber; + } else if (target === "triggering") { + itemNumber = triggeringItemNumber; + } else { + itemNumber = Number(target); + } if (!itemNumber || Number.isNaN(Number(itemNumber))) { const error = "No issue/PR number available"; diff --git a/actions/setup/js/add_labels.test.cjs b/actions/setup/js/add_labels.test.cjs index 7006c4ca721..235c456b26a 100644 --- a/actions/setup/js/add_labels.test.cjs +++ b/actions/setup/js/add_labels.test.cjs @@ -112,9 +112,84 @@ describe("add_labels", () => { }); describe("handleAddLabels", () => { - it("should add labels to an issue using explicit item_number", async () => { + it("should ignore a conflicting item_number when target is triggering", async () => { + const handler = await main({ max: 10, target: "triggering" }); + const addLabelsCalls = []; + mockGithub.rest.issues.addLabels = async params => { + addLabelsCalls.push(params); + return {}; + }; + + const result = await handler({ item_number: 456, labels: ["bug"] }, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(123); + expect(addLabelsCalls[0].issue_number).toBe(123); + }); + + it("should default to the triggering item when target is omitted", async () => { const handler = await main({ max: 10 }); const addLabelsCalls = []; + mockGithub.rest.issues.addLabels = async params => { + addLabelsCalls.push(params); + return {}; + }; + + const result = await handler({ item_number: 456, labels: ["bug"] }, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(123); + expect(addLabelsCalls[0].issue_number).toBe(123); + }); + + it("should ignore a conflicting item_number when target is a fixed number", async () => { + const handler = await main({ max: 10, target: "789" }); + const addLabelsCalls = []; + mockGithub.rest.issues.addLabels = async params => { + addLabelsCalls.push(params); + return {}; + }; + + const result = await handler({ item_number: 456, labels: ["bug"] }, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(789); + expect(addLabelsCalls[0].issue_number).toBe(789); + }); + + it("should use a fixed numeric target without an item_number", async () => { + const handler = await main({ max: 10, target: "789" }); + const addLabelsCalls = []; + mockGithub.rest.issues.addLabels = async params => { + addLabelsCalls.push(params); + return {}; + }; + + const result = await handler({ labels: ["bug"] }, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(789); + expect(addLabelsCalls[0].issue_number).toBe(789); + }); + + it("should accept item_number when target is wildcard", async () => { + const handler = await main({ max: 10, target: "*" }); + const addLabelsCalls = []; + mockGithub.rest.issues.addLabels = async params => { + addLabelsCalls.push(params); + return {}; + }; + + const result = await handler({ item_number: 456, labels: ["bug"] }, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(456); + expect(addLabelsCalls[0].issue_number).toBe(456); + }); + + it("should add labels to an issue using explicit item_number", async () => { + const handler = await main({ max: 10, target: "*" }); + const addLabelsCalls = []; mockGithub.rest.issues.addLabels = async params => { addLabelsCalls.push(params); @@ -138,7 +213,7 @@ describe("add_labels", () => { }); it("should accept structured label entries and add normalized label names", async () => { - const handler = await main({ max: 10, issue_intent: true }); + const handler = await main({ max: 10, target: "*", issue_intent: true }); const graphqlMutationCalls = []; const originalGraphql = mockGithub.graphql; @@ -291,7 +366,7 @@ describe("add_labels", () => { }); it("should report a confidence-gated intent label as suggested rather than added", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); mockGithub.rest.issues.get = async () => ({ data: { @@ -460,7 +535,7 @@ describe("add_labels", () => { }); it("should accept issue_number as an alias for item_number", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const addLabelsCalls = []; mockGithub.rest.issues.addLabels = async params => { @@ -482,7 +557,7 @@ describe("add_labels", () => { }); it("should accept pr_number as an alias for item_number", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const addLabelsCalls = []; mockGithub.rest.issues.addLabels = async params => { @@ -504,7 +579,7 @@ describe("add_labels", () => { }); it("should accept pull_number as an alias for item_number", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const addLabelsCalls = []; mockGithub.rest.issues.addLabels = async params => { @@ -609,7 +684,7 @@ describe("add_labels", () => { }); it("should handle invalid item_number", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const result = await handler( { @@ -969,7 +1044,7 @@ describe("add_labels", () => { }); it("should fall back to the REST add-labels endpoint for PRs when using issue_intent (pull_request field)", async () => { - const handler = await main({ max: 10, issue_intent: true }); + const handler = await main({ max: 10, target: "*", issue_intent: true }); const graphqlMutationCalls = []; const addLabelsCalls = []; @@ -1013,7 +1088,7 @@ describe("add_labels", () => { }); it("should fall back to the REST add-labels endpoint for PRs when node_id starts with PR_", async () => { - const handler = await main({ max: 10, issue_intent: true }); + const handler = await main({ max: 10, target: "*", issue_intent: true }); const graphqlMutationCalls = []; const addLabelsCalls = []; @@ -1267,7 +1342,7 @@ describe("add_labels", () => { }); it("should resolve temporary ID in item_number to real issue number", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const addLabelsCalls = []; mockGithub.rest.issues.addLabels = async params => { @@ -1290,7 +1365,7 @@ describe("add_labels", () => { }); it("should defer when item_number is an unresolved temporary ID", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const result = await handler( { @@ -1306,7 +1381,7 @@ describe("add_labels", () => { }); it("should resolve temporary ID with hash prefix in item_number", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const addLabelsCalls = []; mockGithub.rest.issues.addLabels = async params => { @@ -1328,7 +1403,7 @@ describe("add_labels", () => { }); it("should preview labels in staged mode without calling API", async () => { - const handler = await main({ max: 10, staged: true }); + const handler = await main({ max: 10, target: "*", staged: true }); const addLabelsCalls = []; mockGithub.rest.issues.addLabels = async params => { From 57c73a777eb28c4fa0d6cc5ea39a6b8cb1f6e48e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:29:04 +0000 Subject: [PATCH 2/7] Specify add-labels target authorization Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/add_labels.test.cjs | 120 +++++++++--------- .../docs/specs/safe-outputs-specification.md | 17 ++- ...e_outputs_specification_add_labels_test.go | 31 +++++ 3 files changed, 107 insertions(+), 61 deletions(-) create mode 100644 pkg/workflow/safe_outputs_specification_add_labels_test.go diff --git a/actions/setup/js/add_labels.test.cjs b/actions/setup/js/add_labels.test.cjs index 235c456b26a..7738b1a9c0b 100644 --- a/actions/setup/js/add_labels.test.cjs +++ b/actions/setup/js/add_labels.test.cjs @@ -112,79 +112,81 @@ describe("add_labels", () => { }); describe("handleAddLabels", () => { - it("should ignore a conflicting item_number when target is triggering", async () => { - const handler = await main({ max: 10, target: "triggering" }); - const addLabelsCalls = []; - mockGithub.rest.issues.addLabels = async params => { - addLabelsCalls.push(params); - return {}; - }; + describe("AL-005 runtime target authorization", () => { + it("AL-002 ignores a conflicting item_number when target is triggering", async () => { + const handler = await main({ max: 10, target: "triggering" }); + const addLabelsCalls = []; + mockGithub.rest.issues.addLabels = async params => { + addLabelsCalls.push(params); + return {}; + }; - const result = await handler({ item_number: 456, labels: ["bug"] }, {}); + const result = await handler({ item_number: 456, labels: ["bug"] }, {}); - expect(result.success).toBe(true); - expect(result.number).toBe(123); - expect(addLabelsCalls[0].issue_number).toBe(123); - }); + expect(result.success).toBe(true); + expect(result.number).toBe(123); + expect(addLabelsCalls[0].issue_number).toBe(123); + }); - it("should default to the triggering item when target is omitted", async () => { - const handler = await main({ max: 10 }); - const addLabelsCalls = []; - mockGithub.rest.issues.addLabels = async params => { - addLabelsCalls.push(params); - return {}; - }; + it("AL-001 defaults to the triggering item when target is omitted", async () => { + const handler = await main({ max: 10 }); + const addLabelsCalls = []; + mockGithub.rest.issues.addLabels = async params => { + addLabelsCalls.push(params); + return {}; + }; - const result = await handler({ item_number: 456, labels: ["bug"] }, {}); + const result = await handler({ item_number: 456, labels: ["bug"] }, {}); - expect(result.success).toBe(true); - expect(result.number).toBe(123); - expect(addLabelsCalls[0].issue_number).toBe(123); - }); + expect(result.success).toBe(true); + expect(result.number).toBe(123); + expect(addLabelsCalls[0].issue_number).toBe(123); + }); - it("should ignore a conflicting item_number when target is a fixed number", async () => { - const handler = await main({ max: 10, target: "789" }); - const addLabelsCalls = []; - mockGithub.rest.issues.addLabels = async params => { - addLabelsCalls.push(params); - return {}; - }; + it("AL-003 ignores a conflicting item_number when target is a fixed number", async () => { + const handler = await main({ max: 10, target: "789" }); + const addLabelsCalls = []; + mockGithub.rest.issues.addLabels = async params => { + addLabelsCalls.push(params); + return {}; + }; - const result = await handler({ item_number: 456, labels: ["bug"] }, {}); + const result = await handler({ item_number: 456, labels: ["bug"] }, {}); - expect(result.success).toBe(true); - expect(result.number).toBe(789); - expect(addLabelsCalls[0].issue_number).toBe(789); - }); + expect(result.success).toBe(true); + expect(result.number).toBe(789); + expect(addLabelsCalls[0].issue_number).toBe(789); + }); - it("should use a fixed numeric target without an item_number", async () => { - const handler = await main({ max: 10, target: "789" }); - const addLabelsCalls = []; - mockGithub.rest.issues.addLabels = async params => { - addLabelsCalls.push(params); - return {}; - }; + it("AL-003 uses a fixed numeric target without an item_number", async () => { + const handler = await main({ max: 10, target: "789" }); + const addLabelsCalls = []; + mockGithub.rest.issues.addLabels = async params => { + addLabelsCalls.push(params); + return {}; + }; - const result = await handler({ labels: ["bug"] }, {}); + const result = await handler({ labels: ["bug"] }, {}); - expect(result.success).toBe(true); - expect(result.number).toBe(789); - expect(addLabelsCalls[0].issue_number).toBe(789); - }); + expect(result.success).toBe(true); + expect(result.number).toBe(789); + expect(addLabelsCalls[0].issue_number).toBe(789); + }); - it("should accept item_number when target is wildcard", async () => { - const handler = await main({ max: 10, target: "*" }); - const addLabelsCalls = []; - mockGithub.rest.issues.addLabels = async params => { - addLabelsCalls.push(params); - return {}; - }; + it("AL-004 accepts item_number when target is wildcard", async () => { + const handler = await main({ max: 10, target: "*" }); + const addLabelsCalls = []; + mockGithub.rest.issues.addLabels = async params => { + addLabelsCalls.push(params); + return {}; + }; - const result = await handler({ item_number: 456, labels: ["bug"] }, {}); + const result = await handler({ item_number: 456, labels: ["bug"] }, {}); - expect(result.success).toBe(true); - expect(result.number).toBe(456); - expect(addLabelsCalls[0].issue_number).toBe(456); + expect(result.success).toBe(true); + expect(result.number).toBe(456); + expect(addLabelsCalls[0].issue_number).toBe(456); + }); }); it("should add labels to an issue using explicit item_number", async () => { diff --git a/docs/src/content/docs/specs/safe-outputs-specification.md b/docs/src/content/docs/specs/safe-outputs-specification.md index bff8fa645d2..5d1351b1d51 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.0
+**Version**: 1.29.1
**Status**: Working Draft
-**Publication Date**: 2026-09-02
+**Publication Date**: 2026-09-12
**Editor**: GitHub Agentic Workflows Team
**This Version**: [safe-outputs-specification](/gh-aw/specs/safe-outputs-specification/)
**Latest Published Version**: This document @@ -3594,6 +3594,14 @@ For all Linear types, GraphQL source, endpoint, protocol, and host are implement - Requires both `issues: write` and `pull-requests: write` to support labeling both entity types - Labels must exist in repository; non-existent labels generate warnings +**Target Authorization Requirements**: + +- **AL-001**: An omitted `target` configuration MUST be interpreted as `target: "triggering"`. +- **AL-002**: With `target: "triggering"`, the handler MUST use only the issue or pull request number from trusted triggering-event context. It MUST ignore agent-supplied `item_number` and equivalent aliases. +- **AL-003**: With a fixed numeric `target`, the handler MUST use the configured number whether or not the agent supplies an item number. It MUST ignore conflicting agent-supplied target identifiers. +- **AL-004**: Only `target: "*"` MAY select an issue or pull request from an agent-supplied `item_number` or equivalent alias. +- **AL-005**: The privileged handler MUST enforce AL-001 through AL-004 at runtime. Agent-facing schema shaping or prompt instructions MAY reduce invalid requests but MUST NOT replace runtime enforcement. + --- #### Type: remove_labels @@ -5607,6 +5615,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.1** (2026-09-12): + +- **Specified**: Runtime target authorization for `add_labels`. Omitted and explicit `triggering` targets are restricted to trusted event context, fixed numeric targets override agent output, and only wildcard targets may use agent-supplied item numbers. +- **Updated**: Publication metadata to 1.29.1. + **Version 1.29.0** (2026-09-02): - **Added**: `linear_create_issue`, `linear_add_comment`, and `linear_update_issue` Safe Output definitions. diff --git a/pkg/workflow/safe_outputs_specification_add_labels_test.go b/pkg/workflow/safe_outputs_specification_add_labels_test.go new file mode 100644 index 00000000000..af1b083712a --- /dev/null +++ b/pkg/workflow/safe_outputs_specification_add_labels_test.go @@ -0,0 +1,31 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSafeOutputsSpecificationDocumentsAddLabelsTargetAuthorization(t *testing.T) { + specPath := findRepoFile(t, filepath.Join("docs", "src", "content", "docs", "specs", "safe-outputs-specification.md")) + specBytes, err := os.ReadFile(specPath) + require.NoError(t, err, "should read safe outputs specification") + + section := extractSpecTypeSection(t, string(specBytes), "add_labels") + + assert.Contains(t, section, "**AL-001**", "spec should define the omitted target default") + assert.Contains(t, section, "interpreted as `target: \"triggering\"`", "spec should default omitted targets to triggering") + assert.Contains(t, section, "**AL-002**", "spec should define triggering target authorization") + assert.Contains(t, section, "only the issue or pull request number from trusted triggering-event context", "spec should restrict triggering targets to event context") + assert.Contains(t, section, "**AL-003**", "spec should define fixed target authorization") + assert.Contains(t, section, "ignore conflicting agent-supplied target identifiers", "spec should make fixed targets override agent output") + assert.Contains(t, section, "**AL-004**", "spec should define wildcard target authorization") + assert.Contains(t, section, "Only `target: \"*\"` MAY select", "spec should reserve agent-selected targets for wildcard mode") + assert.Contains(t, section, "**AL-005**", "spec should require runtime enforcement") + assert.Contains(t, section, "MUST NOT replace runtime enforcement", "spec should not rely on schema shaping or prompts for authorization") +} From 0fe865ade45d1df29a19af0240b651db9c106246 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:02:28 +0000 Subject: [PATCH 3/7] Enforce label target restrictions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/remove_labels.cjs | 19 +++- actions/setup/js/remove_labels.test.cjs | 95 +++++++++++++++++-- actions/setup/js/replace_label.cjs | 17 +++- actions/setup/js/replace_label.test.cjs | 79 +++++++++++++++ .../docs/specs/safe-outputs-specification.md | 15 ++- pkg/workflow/replace_label_formal_test.go | 26 +++-- ...utputs_specification_remove_labels_test.go | 26 +++++ specs/replace-label-spec.md | 56 +++++++---- 8 files changed, 288 insertions(+), 45 deletions(-) create mode 100644 pkg/workflow/safe_outputs_specification_remove_labels_test.go diff --git a/actions/setup/js/remove_labels.cjs b/actions/setup/js/remove_labels.cjs index a46444b150d..3fe8a7fd90d 100644 --- a/actions/setup/js/remove_labels.cjs +++ b/actions/setup/js/remove_labels.cjs @@ -29,6 +29,7 @@ const main = createCountGatedHandler({ // Extract configuration const allowedLabels = config.allowed || []; const blockedPatterns = config.blocked || []; + const target = config.target || "triggering"; const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : []; const requiredTitlePrefix = config.required_title_prefix || ""; const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); @@ -67,12 +68,20 @@ const main = createCountGatedHandler({ const { repo: itemRepo, repoParts } = repoResult; core.info(`Target repository: ${itemRepo}`); - // Determine target issue/PR number - // Accept common aliases: issue_number, pr_number, and pull_number are normalised to item_number - const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE }); - if (!targetResult.success) return targetResult; const effectiveContext = resolveInvocationContext(context); - const itemNumber = targetResult.number ?? effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number; + const triggeringItemNumber = effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number; + let itemNumber; + + if (target === "*") { + // Accept common aliases: issue_number, pr_number, and pull_number are normalised to item_number + const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE }); + if (!targetResult.success) return targetResult; + itemNumber = targetResult.number ?? triggeringItemNumber; + } else if (target === "triggering") { + itemNumber = triggeringItemNumber; + } else { + itemNumber = Number(target); + } if (!itemNumber || Number.isNaN(Number(itemNumber))) { const error = "No issue/PR number available"; diff --git a/actions/setup/js/remove_labels.test.cjs b/actions/setup/js/remove_labels.test.cjs index a46965b905e..c36fdb0f19f 100644 --- a/actions/setup/js/remove_labels.test.cjs +++ b/actions/setup/js/remove_labels.test.cjs @@ -81,8 +81,85 @@ describe("remove_labels", () => { }); describe("handleRemoveLabels", () => { + describe("runtime target authorization", () => { + it("RML-002 ignores a conflicting item_number when target is triggering", async () => { + const handler = await main({ max: 10, target: "triggering" }); + const removeLabelCalls = []; + mockGithub.rest.issues.removeLabel = async params => { + removeLabelCalls.push(params); + return {}; + }; + + const result = await handler({ item_number: 456, labels: ["bug"] }, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(123); + expect(removeLabelCalls[0].issue_number).toBe(123); + }); + + it("RML-001 defaults to the triggering item when target is omitted", async () => { + const handler = await main({ max: 10 }); + const removeLabelCalls = []; + mockGithub.rest.issues.removeLabel = async params => { + removeLabelCalls.push(params); + return {}; + }; + + const result = await handler({ item_number: 456, labels: ["bug"] }, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(123); + expect(removeLabelCalls[0].issue_number).toBe(123); + }); + + it("RML-003 ignores a conflicting item_number when target is a fixed number", async () => { + const handler = await main({ max: 10, target: "789" }); + const removeLabelCalls = []; + mockGithub.rest.issues.removeLabel = async params => { + removeLabelCalls.push(params); + return {}; + }; + + const result = await handler({ item_number: 456, labels: ["bug"] }, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(789); + expect(removeLabelCalls[0].issue_number).toBe(789); + }); + + it("RML-003 uses a fixed numeric target without an item_number", async () => { + const handler = await main({ max: 10, target: "789" }); + const removeLabelCalls = []; + mockGithub.rest.issues.removeLabel = async params => { + removeLabelCalls.push(params); + return {}; + }; + + const result = await handler({ labels: ["bug"] }, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(789); + expect(removeLabelCalls[0].issue_number).toBe(789); + }); + + it("RML-004 accepts item_number when target is wildcard", async () => { + const handler = await main({ max: 10, target: "*" }); + const removeLabelCalls = []; + mockGithub.rest.issues.removeLabel = async params => { + removeLabelCalls.push(params); + return {}; + }; + + const result = await handler({ item_number: 456, labels: ["bug"] }, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(456); + expect(removeLabelCalls[0].issue_number).toBe(456); + }); + }); + it("should remove labels from an issue using explicit item_number", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const removeLabelCalls = []; mockGithub.rest.issues.removeLabel = async params => { @@ -108,7 +185,7 @@ describe("remove_labels", () => { }); it("should accept structured label entries and remove normalized label names", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const removeLabelCalls = []; mockGithub.rest.issues.removeLabel = async params => { @@ -131,7 +208,7 @@ describe("remove_labels", () => { }); it("should accept issue_number as an alias for item_number", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const removeLabelCalls = []; mockGithub.rest.issues.removeLabel = async params => { @@ -153,7 +230,7 @@ describe("remove_labels", () => { }); it("should accept pr_number as an alias for item_number", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const removeLabelCalls = []; mockGithub.rest.issues.removeLabel = async params => { @@ -175,7 +252,7 @@ describe("remove_labels", () => { }); it("should accept pull_number as an alias for item_number", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const removeLabelCalls = []; mockGithub.rest.issues.removeLabel = async params => { @@ -280,7 +357,7 @@ describe("remove_labels", () => { }); it("should handle invalid item_number", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const result = await handler( { @@ -654,7 +731,7 @@ describe("remove_labels", () => { }); it("should resolve temporary ID in item_number to real issue number", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const removeLabelCalls = []; mockGithub.rest.issues.removeLabel = async params => { @@ -677,7 +754,7 @@ describe("remove_labels", () => { }); it("should defer when item_number is an unresolved temporary ID", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const result = await handler( { @@ -693,7 +770,7 @@ describe("remove_labels", () => { }); it("should resolve temporary ID with hash prefix in item_number", async () => { - const handler = await main({ max: 10 }); + const handler = await main({ max: 10, target: "*" }); const removeLabelCalls = []; mockGithub.rest.issues.removeLabel = async params => { diff --git a/actions/setup/js/replace_label.cjs b/actions/setup/js/replace_label.cjs index 5676fe74ad4..aebc31505b8 100644 --- a/actions/setup/js/replace_label.cjs +++ b/actions/setup/js/replace_label.cjs @@ -79,6 +79,7 @@ function validateSingleLabel(labelName, allowedPatterns, blockedPatterns, fieldN const main = createCountGatedHandler({ handlerType: HANDLER_TYPE, setup: async (config, maxCount, isStaged) => { + const target = config.target || "triggering"; const currentAllowedAdd = () => (Array.isArray(config.allowed_add) ? config.allowed_add : []); const currentAllowedRemove = () => (Array.isArray(config.allowed_remove) ? config.allowed_remove : []); const currentBlockedPatterns = () => (Array.isArray(config.blocked) ? config.blocked : []); @@ -120,11 +121,19 @@ const main = createCountGatedHandler({ const { repo: itemRepo, repoParts } = repoResult; core.info(`Target repository: ${itemRepo}`); - // Determine target issue/PR number - const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE }); - if (!targetResult.success) return targetResult; const effectiveContext = resolveInvocationContext(context); - const itemNumber = targetResult.number ?? effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number; + const triggeringItemNumber = effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number; + let itemNumber; + + if (target === "*") { + const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE }); + if (!targetResult.success) return targetResult; + itemNumber = targetResult.number ?? triggeringItemNumber; + } else if (target === "triggering") { + itemNumber = triggeringItemNumber; + } else { + itemNumber = Number(target); + } if (!itemNumber || Number.isNaN(Number(itemNumber))) { const error = "No issue/PR number available"; diff --git a/actions/setup/js/replace_label.test.cjs b/actions/setup/js/replace_label.test.cjs index a0f4fcb7e38..a4f907dd32d 100644 --- a/actions/setup/js/replace_label.test.cjs +++ b/actions/setup/js/replace_label.test.cjs @@ -69,6 +69,85 @@ describe("replace_label", () => { global.context = mockContext; }); + describe("runtime target authorization", () => { + const message = { label_to_remove: "in-progress", label_to_add: "done", item_number: 99 }; + + it("T-RL-015 ignores a conflicting item_number when target is triggering", async () => { + const setLabelsCalls = []; + mockGithub.rest.issues.setLabels = async params => { + setLabelsCalls.push(params); + return { data: params.labels.map(name => ({ name })) }; + }; + const handler = await main({ target: "triggering" }); + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(42); + expect(setLabelsCalls[0].issue_number).toBe(42); + }); + + it("T-RL-014 defaults to the triggering item when target is omitted", async () => { + const setLabelsCalls = []; + mockGithub.rest.issues.setLabels = async params => { + setLabelsCalls.push(params); + return { data: params.labels.map(name => ({ name })) }; + }; + const handler = await main({}); + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(42); + expect(setLabelsCalls[0].issue_number).toBe(42); + }); + + it("T-RL-016 ignores a conflicting item_number when target is a fixed number", async () => { + const setLabelsCalls = []; + mockGithub.rest.issues.setLabels = async params => { + setLabelsCalls.push(params); + return { data: params.labels.map(name => ({ name })) }; + }; + const handler = await main({ target: "123" }); + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(123); + expect(setLabelsCalls[0].issue_number).toBe(123); + }); + + it("T-RL-017 uses a fixed numeric target without an item_number", async () => { + const setLabelsCalls = []; + mockGithub.rest.issues.setLabels = async params => { + setLabelsCalls.push(params); + return { data: params.labels.map(name => ({ name })) }; + }; + const handler = await main({ target: "123" }); + + const result = await handler({ label_to_remove: "in-progress", label_to_add: "done" }, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(123); + expect(setLabelsCalls[0].issue_number).toBe(123); + }); + + it("T-RL-018 accepts item_number when target is wildcard", async () => { + const setLabelsCalls = []; + mockGithub.rest.issues.setLabels = async params => { + setLabelsCalls.push(params); + return { data: params.labels.map(name => ({ name })) }; + }; + const handler = await main({ target: "*" }); + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(99); + expect(setLabelsCalls[0].issue_number).toBe(99); + }); + }); + it("should replace label when both labels are valid", async () => { const handler = await main({ allowed_add: [], allowed_remove: [], blocked: [] }); const result = await handler({ label_to_remove: "in-progress", label_to_add: "done" }, {}); diff --git a/docs/src/content/docs/specs/safe-outputs-specification.md b/docs/src/content/docs/specs/safe-outputs-specification.md index 5d1351b1d51..554a7367cd2 100644 --- a/docs/src/content/docs/specs/safe-outputs-specification.md +++ b/docs/src/content/docs/specs/safe-outputs-specification.md @@ -7,7 +7,7 @@ sidebar: # Safe Outputs MCP Gateway Specification -**Version**: 1.29.1
+**Version**: 1.29.2
**Status**: Working Draft
**Publication Date**: 2026-09-12
**Editor**: GitHub Agentic Workflows Team
@@ -3630,6 +3630,14 @@ For all Linear types, GraphQL source, endpoint, protocol, and host are implement - Same permissions as `add_labels` - Missing labels are silently ignored (no error) +**Target Authorization Requirements**: + +- **RML-001**: An omitted `target` configuration MUST be interpreted as `target: "triggering"`. +- **RML-002**: With `target: "triggering"`, the handler MUST use only the issue or pull request number from trusted triggering-event context. It MUST ignore agent-supplied `item_number` and equivalent aliases. +- **RML-003**: With a fixed numeric `target`, the handler MUST use the configured number whether or not the agent supplies an item number. It MUST ignore conflicting agent-supplied target identifiers. +- **RML-004**: Only `target: "*"` MAY select an issue or pull request from an agent-supplied `item_number` or equivalent alias. +- **RML-005**: The privileged handler MUST enforce RML-001 through RML-004 at runtime. + --- #### Type: add_reviewer @@ -5615,6 +5623,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.2** (2026-09-12): + +- **Specified**: Runtime target authorization for `remove_labels`, matching `add_labels`: omitted and explicit `triggering` targets are restricted to trusted event context, fixed numeric targets override agent output, and only wildcard targets may use agent-supplied item numbers. +- **Updated**: Publication metadata to 1.29.2. + **Version 1.29.1** (2026-09-12): - **Specified**: Runtime target authorization for `add_labels`. Omitted and explicit `triggering` targets are restricted to trusted event context, fixed numeric targets override agent output, and only wildcard targets may use agent-supplied item numbers. diff --git a/pkg/workflow/replace_label_formal_test.go b/pkg/workflow/replace_label_formal_test.go index 5c73101ca66..cdde8132ea0 100644 --- a/pkg/workflow/replace_label_formal_test.go +++ b/pkg/workflow/replace_label_formal_test.go @@ -478,13 +478,27 @@ func TestFormalReplaceLabelP14_CrossRepoRestriction(t *testing.T) { } func TestFormalReplaceLabelP15_TargetModeEnforcement(t *testing.T) { - n, ok := formalResolveTargetNumber("triggering", 42, 99) - require.True(t, ok) - assert.Equal(t, 42, n) + tests := []struct { + name string + target string + trigger int + requested int + expected int + }{ + {name: "T-RL-014 omitted target uses triggering item", trigger: 42, requested: 99, expected: 42}, + {name: "T-RL-015 triggering target ignores requested item", target: "triggering", trigger: 42, requested: 99, expected: 42}, + {name: "T-RL-016 fixed target ignores requested item", target: "123", trigger: 42, requested: 99, expected: 123}, + {name: "T-RL-017 fixed target works without requested item", target: "123", trigger: 42, expected: 123}, + {name: "T-RL-018 wildcard target uses requested item", target: "*", trigger: 42, requested: 99, expected: 99}, + } - n, ok = formalResolveTargetNumber("*", 42, 99) - require.True(t, ok) - assert.Equal(t, 99, n) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n, ok := formalResolveTargetNumber(tt.target, tt.trigger, tt.requested) + require.True(t, ok) + assert.Equal(t, tt.expected, n) + }) + } } func TestFormalReplaceLabelEdge_BothLabelsIdentical(t *testing.T) { diff --git a/pkg/workflow/safe_outputs_specification_remove_labels_test.go b/pkg/workflow/safe_outputs_specification_remove_labels_test.go new file mode 100644 index 00000000000..101619b8181 --- /dev/null +++ b/pkg/workflow/safe_outputs_specification_remove_labels_test.go @@ -0,0 +1,26 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSafeOutputsSpecificationDocumentsRemoveLabelsTargetAuthorization(t *testing.T) { + specPath := findRepoFile(t, filepath.Join("docs", "src", "content", "docs", "specs", "safe-outputs-specification.md")) + specBytes, err := os.ReadFile(specPath) + require.NoError(t, err, "should read safe outputs specification") + + section := extractSpecTypeSection(t, string(specBytes), "remove_labels") + + assert.Contains(t, section, "**RML-001**", "spec should define the omitted target default") + assert.Contains(t, section, "**RML-002**", "spec should restrict triggering targets to event context") + assert.Contains(t, section, "**RML-003**", "spec should make fixed targets override agent output") + assert.Contains(t, section, "**RML-004**", "spec should reserve agent-selected targets for wildcard mode") + assert.Contains(t, section, "**RML-005**", "spec should require runtime enforcement") +} diff --git a/specs/replace-label-spec.md b/specs/replace-label-spec.md index eeffdd4a1a8..98647c43fc1 100644 --- a/specs/replace-label-spec.md +++ b/specs/replace-label-spec.md @@ -7,11 +7,11 @@ sidebar: # replace-label Safe-Output Type Specification -**Version**: 1.0.0 +**Version**: 1.0.2 **Status**: Candidate Recommendation **Latest Version**: https://github.com/github/gh-aw/blob/main/specs/replace-label-spec.md **Editors**: GitHub gh-aw Team (GitHub, Inc.) -**Publication Date**: 2026-06-20 +**Publication Date**: 2026-09-12 --- @@ -196,7 +196,7 @@ AI agents emit `replace_label` messages as part of the safe-outputs protocol. Th |-------|------|----------|------------|-------------| | `label_to_remove` | `string` | Yes | 128 characters | Name of the label to remove from the target item. The label need not currently be present on the item (see §5.3.4). | | `label_to_add` | `string` | Yes | 128 characters | Name of the label to add to the target item. The label need not pre-exist in the repository (see §5.4). | -| `item_number` | `integer` or temporary-ID `string` | No | — | Issue or pull request number to target. When absent, falls back to the triggering item derived from the GitHub Actions event context. May be a temporary-ID string resolved by the gh-aw temporary-ID framework. | +| `item_number` | `integer` or temporary-ID `string` | No | — | Issue or pull request number to target when `target: "*"` is configured. When absent, falls back to the triggering item derived from the GitHub Actions event context. May be a temporary-ID string resolved by the gh-aw temporary-ID framework. | | `repo` | `string` | No | 256 characters | Target repository in `owner/repo` format. Overrides the configured `target-repo` for this message only. Must satisfy the `allowed-repos` configuration constraint. | **RL-004**: A conforming implementation MUST reject any `replace_label` message in which `label_to_remove` is absent, empty after trimming, or exceeds 128 characters. @@ -209,7 +209,7 @@ AI agents emit `replace_label` messages as part of the safe-outputs protocol. Th #### 4.2.2 Aliased Item Number Fields -For compatibility with agents that follow other safe-output conventions, the handler MUST also accept the following field names as aliases for `item_number`: +With `target: "*"`, the handler MUST also accept the following field names as aliases for `item_number`: - `issue_number` - `pr_number` @@ -315,22 +315,19 @@ Messages that fail schema validation MUST be rejected with a structured error lo #### 5.3.2 Item Number Resolution -**RL-016**: The target item number is resolved as follows, in priority order: +**RL-016**: The target item number MUST be resolved from the configured `target` mode before considering agent-supplied fields. Omitted `target` MUST be interpreted as `"triggering"`. -1. The item number resolved from any temporary-ID field (`item_number`, `issue_number`, `pr_number`, `pull_number`) via the gh-aw temporary-ID framework. -2. A literal numeric value from the same aliased fields. -3. The triggering issue number from `github.event.issue.number`. -4. The triggering pull request number from `github.event.pull_request.number`. - -**RL-017**: When no item number can be resolved through any of the four mechanisms above, the message MUST be rejected with the error "No issue/PR number available". +**RL-017**: When no item number can be resolved for the configured target mode, the message MUST be rejected with the error "No issue/PR number available". #### 5.3.3 Target Mode Enforcement -**RL-018**: When `target` is set to `"triggering"`, the resolved item number MUST equal the triggering item's number. A message specifying a different `item_number` MUST be rejected. +**RL-018**: When `target` is set to `"triggering"` or omitted, the handler MUST use the triggering item's number and MUST ignore agent-supplied item-number fields. + +**RL-019**: When `target` is set to an explicit integer, the handler MUST use that integer and MUST ignore agent-supplied item-number fields. -**RL-019**: When `target` is set to an explicit integer, the resolved item number MUST equal that integer. Messages specifying a different number MUST be rejected. +**RL-020**: Only when `target` is set to `"*"` MAY the handler resolve an item number from `item_number`, `issue_number`, `pr_number`, or `pull_number`, subject to repository constraints. If no agent-supplied number is present, the handler MAY fall back to the triggering item. -**RL-020**: When `target` is set to `"*"`, any item number is permitted, subject to repository constraints. +**RL-020a**: The privileged handler MUST enforce RL-016 and RL-018 through RL-020 at runtime. ### 5.4 Stage 4: Label Validation @@ -561,7 +558,15 @@ For outcome evaluation compliance (verifying that the `replace_label` outcome ev - **T-RL-012**: Verify that the default max of 5 is enforced when `max` is absent from configuration. - **T-RL-013**: Verify that a GHA expression in `max` is resolved at runtime. -#### 9.2.3 Label Validation Tests +#### 9.2.3 Target Authorization Tests + +- **T-RL-014**: Verify that an omitted `target` ignores a conflicting agent-supplied item number and uses the triggering item. +- **T-RL-015**: Verify that `target: "triggering"` ignores a conflicting agent-supplied item number. +- **T-RL-016**: Verify that a fixed numeric target ignores a conflicting agent-supplied item number. +- **T-RL-017**: Verify that a fixed numeric target is used when the message omits an item number. +- **T-RL-018**: Verify that `target: "*"` accepts an agent-supplied item number. + +#### 9.2.4 Label Validation Tests The normative compliance fixtures for the allowlist and blocklist edge cases in this subsection live in `specs/replace-label-compliance/rl-002-allowlist-enforcement.yaml` @@ -583,20 +588,20 @@ Fixture linkage check (2026-08-01): - [x] T-RL-024 covered by `specs/replace-label-compliance/rl-003-blocklist-ordering.yaml` - [x] T-RL-025 covered by `specs/replace-label-compliance/rl-002-allowlist-enforcement.yaml` -#### 9.2.4 Gate Check Tests +#### 9.2.5 Gate Check Tests - **T-RL-030**: Verify that an item satisfying all `required-labels` proceeds to the mutation stage. - **T-RL-031**: Verify that an item missing a required label is skipped (`skipped: true`) without failing. - **T-RL-032**: Verify that an item with a title matching `required-title-prefix` proceeds. - **T-RL-033**: Verify that an item whose title does not match `required-title-prefix` is skipped without failing. -#### 9.2.5 Label Set Computation Tests +#### 9.2.6 Label Set Computation Tests - **T-RL-040**: Verify that when `label_to_remove` is on the item, the computed new label set excludes it and includes `label_to_add`. - **T-RL-041**: Verify that when `label_to_add` is passed to `setLabels` and the label does not exist in the repository, the call fails with a hard error. - **T-RL-044**: Verify that when `label_to_remove` is not on the item, the computed new label set adds `label_to_add` without removing any label. -#### 9.2.6 REST setLabels Tests +#### 9.2.7 REST setLabels Tests - **T-RL-050**: Verify that `setLabels` is called with the correct `owner`, `repo`, `issue_number`, and `labels` array. - **T-RL-051**: Verify that the updated label list returned by `setLabels` is logged. @@ -604,13 +609,13 @@ Fixture linkage check (2026-08-01): - **T-RL-053**: Verify that `label_to_add` always appears exactly once in the `labels` array. - **T-RL-054**: Verify that rate-limit responses trigger retry behavior. -#### 9.2.7 Staged Mode Tests +#### 9.2.8 Staged Mode Tests - **T-RL-060**: Verify that no write API call is made when `staged: true`. - **T-RL-061**: Verify that the preview log entry includes the correct label names, item number, and repository. - **T-RL-062**: Verify that staged mode returns `{ success: true, staged: true }`. -#### 9.2.8 Cross-Repository Tests +#### 9.2.9 Cross-Repository Tests - **T-RL-070**: Verify that a message with a `repo` in `allowed-repos` is accepted. - **T-RL-071**: Verify that a message with a `repo` not in `allowed-repos` is rejected. @@ -629,7 +634,11 @@ Fixture linkage check (2026-08-01): | RL-007 String sanitization | T-RL-006 | 1 | Required | | RL-010 Count gate enforcement | T-RL-010, T-RL-011 | 1 | Required | | RL-012 Default max = 5 | T-RL-012 | 1 | Required | +| RL-016 Target mode precedence | T-RL-014 – T-RL-018 | 1 | Required | | RL-017 No item number error | T-RL-006 | 1 | Required | +| RL-018 Triggering target authorization | T-RL-014, T-RL-015 | 1 | Required | +| RL-019 Fixed target authorization | T-RL-016, T-RL-017 | 1 | Required | +| RL-020 Wildcard target authorization | T-RL-018 | 1 | Required | | RL-024 required-labels gate | T-RL-030, T-RL-031 | 1 | Required | | RL-025 required-title-prefix gate | T-RL-032, T-RL-033 | 1 | Required | | RL-027 Staged mode no writes | T-RL-060 | 1 | Required | @@ -700,6 +709,7 @@ With `staged: true` in the configuration: safe-outputs: replace-label: staged: true + target: "*" allowed-add: ["done"] allowed-remove: ["in-progress"] ``` @@ -727,6 +737,7 @@ The message is rejected with `{ success: false }`. The label must be created in ```yaml safe-outputs: replace-label: + target: "*" target-repo: "owner/infra" allowed-repos: ["owner/infra", "owner/platform"] allowed-add: ["deployed"] @@ -797,6 +808,11 @@ The message is skipped. The workflow run is not marked as failed. ## Change Log +### Version 1.0.2 (Revision) — 2026-09-12 + +- Clarified that configured target modes take precedence over agent-supplied item numbers. +- Added T-RL-014 through T-RL-018 target authorization tests. + ### Version 1.0.1 (Revision) — 2026-06-22 - Replaced GraphQL mutation (Stage 8) with a single REST `PUT /repos/{owner}/{repo}/issues/{issue_number}/labels` call (`setLabels`), achieving true atomicity: either the entire label set update succeeds or fails with no partial-success scenario. From b42c16940ea79547c6bbbf343a34a87ffa937119 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:18:39 +0000 Subject: [PATCH 4/7] Harden update issue target resolution Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_handler_factory.cjs | 4 +- .../setup/js/update_handler_factory.test.cjs | 60 ++++++++++++++++++- .../docs/specs/safe-outputs-specification.md | 18 +++++- ...outputs_specification_update_issue_test.go | 26 ++++++++ 4 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 pkg/workflow/safe_outputs_specification_update_issue_test.go diff --git a/actions/setup/js/update_handler_factory.cjs b/actions/setup/js/update_handler_factory.cjs index e635a4650e0..0d759b00427 100644 --- a/actions/setup/js/update_handler_factory.cjs +++ b/actions/setup/js/update_handler_factory.cjs @@ -44,10 +44,10 @@ function createStandardResolveNumber(config) { const { itemType, itemNumberField, supportsPR, supportsIssue } = config; return function resolveNumber(item, updateTarget, context, resolvedTemporaryIds) { - // Resolve temporary IDs in the item number field before target resolution + // Resolve model-provided temporary IDs only when wildcard targeting allows them. let resolvedItem = item; const itemNumberValue = item[itemNumberField]; - if (resolvedTemporaryIds && itemNumberValue != null) { + if (updateTarget === "*" && resolvedTemporaryIds && itemNumberValue != null) { const tempIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds); const resolvedTarget = resolveRepoIssueTarget(itemNumberValue, tempIdMap, context.repo.owner, context.repo.repo); if (resolvedTarget.wasTemporaryId && resolvedTarget.resolved) { diff --git a/actions/setup/js/update_handler_factory.test.cjs b/actions/setup/js/update_handler_factory.test.cjs index 1d76f485513..b8d4d157eb9 100644 --- a/actions/setup/js/update_handler_factory.test.cjs +++ b/actions/setup/js/update_handler_factory.test.cjs @@ -417,7 +417,7 @@ describe("update_handler_factory.cjs", () => { }); describe("createStandardResolveNumber", () => { - it("should create a resolve function that uses resolveTarget helper", async () => { + it("UI-001 defaults an omitted target to the triggering issue", async () => { const resolveNumber = factoryModule.createStandardResolveNumber({ itemType: "update_issue", itemNumberField: "issue_number", @@ -425,7 +425,21 @@ describe("update_handler_factory.cjs", () => { supportsIssue: true, }); - const item = { issue_number: 42 }; + const result = resolveNumber({ issue_number: 99 }, undefined, mockContext); + + expect(result.success).toBe(true); + expect(result.number).toBe(42); + }); + + it("UI-002 ignores a conflicting issue_number when target is triggering", async () => { + const resolveNumber = factoryModule.createStandardResolveNumber({ + itemType: "update_issue", + itemNumberField: "issue_number", + supportsPR: false, + supportsIssue: true, + }); + + const item = { issue_number: 99 }; const updateTarget = "triggering"; const context = mockContext; @@ -435,6 +449,48 @@ describe("update_handler_factory.cjs", () => { expect(result.number).toBe(42); }); + it("UI-003 ignores a conflicting issue_number when target is fixed", async () => { + const resolveNumber = factoryModule.createStandardResolveNumber({ + itemType: "update_issue", + itemNumberField: "issue_number", + supportsPR: false, + supportsIssue: true, + }); + + const result = resolveNumber({ issue_number: 99 }, "17", mockContext); + + expect(result.success).toBe(true); + expect(result.number).toBe(17); + }); + + it("UI-004 accepts issue_number when target is wildcard", async () => { + const resolveNumber = factoryModule.createStandardResolveNumber({ + itemType: "update_issue", + itemNumberField: "issue_number", + supportsPR: false, + supportsIssue: true, + }); + + const result = resolveNumber({ issue_number: 99 }, "*", mockContext); + + expect(result.success).toBe(true); + expect(result.number).toBe(99); + }); + + it("UI-005 ignores unresolved temporary IDs when target is triggering", async () => { + const resolveNumber = factoryModule.createStandardResolveNumber({ + itemType: "update_issue", + itemNumberField: "issue_number", + supportsPR: false, + supportsIssue: true, + }); + + const result = resolveNumber({ issue_number: "aw_pending" }, "triggering", mockContext, {}); + + expect(result.success).toBe(true); + expect(result.number).toBe(42); + }); + it("should handle different item number fields", async () => { const resolveNumber = factoryModule.createStandardResolveNumber({ itemType: "update_pull_request", diff --git a/docs/src/content/docs/specs/safe-outputs-specification.md b/docs/src/content/docs/specs/safe-outputs-specification.md index 554a7367cd2..5cba5b85369 100644 --- a/docs/src/content/docs/specs/safe-outputs-specification.md +++ b/docs/src/content/docs/specs/safe-outputs-specification.md @@ -7,7 +7,7 @@ sidebar: # Safe Outputs MCP Gateway Specification -**Version**: 1.29.2
+**Version**: 1.29.3
**Status**: Working Draft
**Publication Date**: 2026-09-12
**Editor**: GitHub Agentic Workflows Team
@@ -2715,6 +2715,7 @@ This section provides complete definitions for all remaining safe output types. **Configuration Parameters**: - `max`: Operation limit (default: 1) +- `target`: `"triggering"` (default), `"*"`, or a fixed issue number - `target-repo`: Cross-repository target - `allowed-repos`: Cross-repo allowlist - `staged`: Staged mode override @@ -2726,6 +2727,16 @@ This section provides complete definitions for all remaining safe output types. - Cross-repository targets MUST be validated against the `allowed-repos` allowlist - Issue number MUST be validated as a positive integer belonging to the target repository +**UI-001**: If `target` is omitted, the processor MUST interpret it as `target: "triggering"`. + +**UI-002**: For `target: "triggering"`, the processor MUST use only the issue number from trusted triggering-event context and MUST ignore any agent-supplied `issue_number`. + +**UI-003**: For a fixed numeric `target`, the processor MUST use the configured issue number and MUST ignore any conflicting agent-supplied `issue_number`. + +**UI-004**: Only `target: "*"` MAY select an issue from the agent-supplied `issue_number`. + +**UI-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 `"*"`. + **Required Permissions**: *GitHub Actions Token*: @@ -5623,6 +5634,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.3** (2026-09-12): + +- **Specified**: Runtime target authorization for `update_issue`, including wildcard-only resolution of agent-supplied temporary issue IDs. +- **Updated**: Publication metadata to 1.29.3. + **Version 1.29.2** (2026-09-12): - **Specified**: Runtime target authorization for `remove_labels`, matching `add_labels`: omitted and explicit `triggering` targets are restricted to trusted event context, fixed numeric targets override agent output, and only wildcard targets may use agent-supplied item numbers. diff --git a/pkg/workflow/safe_outputs_specification_update_issue_test.go b/pkg/workflow/safe_outputs_specification_update_issue_test.go new file mode 100644 index 00000000000..685a23ccada --- /dev/null +++ b/pkg/workflow/safe_outputs_specification_update_issue_test.go @@ -0,0 +1,26 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSafeOutputsSpecificationDocumentsUpdateIssueTargetAuthorization(t *testing.T) { + specPath := findRepoFile(t, filepath.Join("docs", "src", "content", "docs", "specs", "safe-outputs-specification.md")) + specBytes, err := os.ReadFile(specPath) + require.NoError(t, err, "should read safe outputs specification") + + section := extractSpecTypeSection(t, string(specBytes), "update_issue") + + assert.Contains(t, section, "**UI-001**", "spec should define the omitted target default") + assert.Contains(t, section, "**UI-002**", "spec should restrict triggering targets to event context") + assert.Contains(t, section, "**UI-003**", "spec should make fixed targets override agent output") + assert.Contains(t, section, "**UI-004**", "spec should reserve agent-selected targets for wildcard mode") + assert.Contains(t, section, "**UI-005**", "spec should require runtime target authorization before temporary-ID resolution") +} From 471e84ee3c1781cbc7dddcc07dff8569a70975b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:50:57 +0000 Subject: [PATCH 5/7] Validate safe output numeric targets Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/add_labels.cjs | 3 ++- actions/setup/js/add_labels.test.cjs | 14 ++++++++++++++ actions/setup/js/remove_labels.cjs | 3 ++- actions/setup/js/remove_labels.test.cjs | 14 ++++++++++++++ actions/setup/js/replace_label.cjs | 3 ++- actions/setup/js/replace_label.test.cjs | 14 ++++++++++++++ specs/replace-label-spec.md | 2 +- 7 files changed, 49 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/add_labels.cjs b/actions/setup/js/add_labels.cjs index b4d15b3e266..48479291505 100644 --- a/actions/setup/js/add_labels.cjs +++ b/actions/setup/js/add_labels.cjs @@ -264,7 +264,8 @@ const main = createCountGatedHandler({ itemNumber = Number(target); } - if (!itemNumber || Number.isNaN(Number(itemNumber))) { + itemNumber = Number(itemNumber); + if (!Number.isInteger(itemNumber) || itemNumber <= 0) { const error = "No issue/PR number available"; core.warning(error); return { success: false, error }; diff --git a/actions/setup/js/add_labels.test.cjs b/actions/setup/js/add_labels.test.cjs index 7738b1a9c0b..c39e068d443 100644 --- a/actions/setup/js/add_labels.test.cjs +++ b/actions/setup/js/add_labels.test.cjs @@ -173,6 +173,20 @@ describe("add_labels", () => { expect(addLabelsCalls[0].issue_number).toBe(789); }); + it.each([-1, 1.5, Infinity])("rejects invalid fixed target %s", async target => { + const handler = await main({ max: 10, target }); + const addLabelsCalls = []; + mockGithub.rest.issues.addLabels = async params => { + addLabelsCalls.push(params); + return {}; + }; + + const result = await handler({ labels: ["bug"] }, {}); + + expect(result.success).toBe(false); + expect(addLabelsCalls).toHaveLength(0); + }); + it("AL-004 accepts item_number when target is wildcard", async () => { const handler = await main({ max: 10, target: "*" }); const addLabelsCalls = []; diff --git a/actions/setup/js/remove_labels.cjs b/actions/setup/js/remove_labels.cjs index 3fe8a7fd90d..93b455a0d61 100644 --- a/actions/setup/js/remove_labels.cjs +++ b/actions/setup/js/remove_labels.cjs @@ -83,7 +83,8 @@ const main = createCountGatedHandler({ itemNumber = Number(target); } - if (!itemNumber || Number.isNaN(Number(itemNumber))) { + itemNumber = Number(itemNumber); + if (!Number.isInteger(itemNumber) || itemNumber <= 0) { const error = "No issue/PR number available"; core.warning(error); return { success: false, error }; diff --git a/actions/setup/js/remove_labels.test.cjs b/actions/setup/js/remove_labels.test.cjs index c36fdb0f19f..2408ef3ca9a 100644 --- a/actions/setup/js/remove_labels.test.cjs +++ b/actions/setup/js/remove_labels.test.cjs @@ -142,6 +142,20 @@ describe("remove_labels", () => { expect(removeLabelCalls[0].issue_number).toBe(789); }); + it.each([-1, 1.5, Infinity])("rejects invalid fixed target %s", async target => { + const handler = await main({ max: 10, target }); + const removeLabelCalls = []; + mockGithub.rest.issues.removeLabel = async params => { + removeLabelCalls.push(params); + return {}; + }; + + const result = await handler({ labels: ["bug"] }, {}); + + expect(result.success).toBe(false); + expect(removeLabelCalls).toHaveLength(0); + }); + it("RML-004 accepts item_number when target is wildcard", async () => { const handler = await main({ max: 10, target: "*" }); const removeLabelCalls = []; diff --git a/actions/setup/js/replace_label.cjs b/actions/setup/js/replace_label.cjs index aebc31505b8..75a519da18f 100644 --- a/actions/setup/js/replace_label.cjs +++ b/actions/setup/js/replace_label.cjs @@ -135,7 +135,8 @@ const main = createCountGatedHandler({ itemNumber = Number(target); } - if (!itemNumber || Number.isNaN(Number(itemNumber))) { + itemNumber = Number(itemNumber); + if (!Number.isInteger(itemNumber) || itemNumber <= 0) { const error = "No issue/PR number available"; core.warning(error); return { success: false, error }; diff --git a/actions/setup/js/replace_label.test.cjs b/actions/setup/js/replace_label.test.cjs index a4f907dd32d..f897bf6236c 100644 --- a/actions/setup/js/replace_label.test.cjs +++ b/actions/setup/js/replace_label.test.cjs @@ -132,6 +132,20 @@ describe("replace_label", () => { expect(setLabelsCalls[0].issue_number).toBe(123); }); + it.each([-1, 1.5, Infinity])("rejects invalid fixed target %s", async target => { + const handler = await main({ target }); + const setLabelsCalls = []; + mockGithub.rest.issues.setLabels = async params => { + setLabelsCalls.push(params); + return { data: [] }; + }; + + const result = await handler({ label_to_remove: "in-progress", label_to_add: "done" }, {}); + + expect(result.success).toBe(false); + expect(setLabelsCalls).toHaveLength(0); + }); + it("T-RL-018 accepts item_number when target is wildcard", async () => { const setLabelsCalls = []; mockGithub.rest.issues.setLabels = async params => { diff --git a/specs/replace-label-spec.md b/specs/replace-label-spec.md index 98647c43fc1..16f04a16810 100644 --- a/specs/replace-label-spec.md +++ b/specs/replace-label-spec.md @@ -161,7 +161,7 @@ safe-outputs: | `allowed-remove` | `string[]` | `[]` (any) | Glob patterns for labels the agent is permitted to remove. When empty or absent, no remove restriction applies. | | `blocked` | `string[]` | `[]` (none) | Glob patterns that are unconditionally prohibited for both add and remove operations. Applied after allowlist checks. | | `max` | `integer` or GHA expression | `5` | Maximum number of `replace-label` operations permitted in a single workflow run. Supports GitHub Actions expressions (e.g., `${{ inputs.max_labels }}`). | -| `target` | `"triggering"` \| `"*"` \| integer | `"triggering"` | Determines which issue/PR may be targeted. `"triggering"` restricts to the event item; `"*"` permits any item (requires `item_number` in message); an integer pins to a specific item number. | +| `target` | `"triggering"` \| `"*"` \| integer | `"triggering"` | Determines which issue/PR may be targeted. `"triggering"` restricts to the event item; `"*"` permits an `item_number` from the message, or falls back to the event item when absent; an integer pins to a specific item number. | | `target-repo` | `string` | (current repo) | Default target repository in `owner/repo` format for cross-repository operations. | | `allowed-repos` | `string[]` | `[]` | Additional repositories the agent may target, beyond `target-repo`. | | `github-token` | `string` | (workflow default) | GitHub token or GitHub Actions expression for authentication. Overrides the workflow-level token for this type only. | From a04e12b165c3437f6b8bf9a0a187ab6a88bfc91a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:52:35 +0000 Subject: [PATCH 6/7] Clarify invalid fixed label targets Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/add_labels.cjs | 2 +- actions/setup/js/add_labels.test.cjs | 1 + actions/setup/js/remove_labels.cjs | 2 +- actions/setup/js/remove_labels.test.cjs | 1 + actions/setup/js/replace_label.cjs | 2 +- actions/setup/js/replace_label.test.cjs | 1 + 6 files changed, 6 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/add_labels.cjs b/actions/setup/js/add_labels.cjs index 48479291505..9f4962c58ba 100644 --- a/actions/setup/js/add_labels.cjs +++ b/actions/setup/js/add_labels.cjs @@ -266,7 +266,7 @@ const main = createCountGatedHandler({ itemNumber = Number(itemNumber); if (!Number.isInteger(itemNumber) || itemNumber <= 0) { - const error = "No issue/PR number available"; + const error = target !== "*" && target !== "triggering" ? "Invalid issue/PR number" : "No issue/PR number available"; core.warning(error); return { success: false, error }; } diff --git a/actions/setup/js/add_labels.test.cjs b/actions/setup/js/add_labels.test.cjs index c39e068d443..304332c1350 100644 --- a/actions/setup/js/add_labels.test.cjs +++ b/actions/setup/js/add_labels.test.cjs @@ -184,6 +184,7 @@ describe("add_labels", () => { const result = await handler({ labels: ["bug"] }, {}); expect(result.success).toBe(false); + expect(result.error).toBe("Invalid issue/PR number"); expect(addLabelsCalls).toHaveLength(0); }); diff --git a/actions/setup/js/remove_labels.cjs b/actions/setup/js/remove_labels.cjs index 93b455a0d61..fccec1a12d4 100644 --- a/actions/setup/js/remove_labels.cjs +++ b/actions/setup/js/remove_labels.cjs @@ -85,7 +85,7 @@ const main = createCountGatedHandler({ itemNumber = Number(itemNumber); if (!Number.isInteger(itemNumber) || itemNumber <= 0) { - const error = "No issue/PR number available"; + const error = target !== "*" && target !== "triggering" ? "Invalid issue/PR number" : "No issue/PR number available"; core.warning(error); return { success: false, error }; } diff --git a/actions/setup/js/remove_labels.test.cjs b/actions/setup/js/remove_labels.test.cjs index 2408ef3ca9a..83cbaeff744 100644 --- a/actions/setup/js/remove_labels.test.cjs +++ b/actions/setup/js/remove_labels.test.cjs @@ -153,6 +153,7 @@ describe("remove_labels", () => { const result = await handler({ labels: ["bug"] }, {}); expect(result.success).toBe(false); + expect(result.error).toBe("Invalid issue/PR number"); expect(removeLabelCalls).toHaveLength(0); }); diff --git a/actions/setup/js/replace_label.cjs b/actions/setup/js/replace_label.cjs index 75a519da18f..b4e776d5f9f 100644 --- a/actions/setup/js/replace_label.cjs +++ b/actions/setup/js/replace_label.cjs @@ -137,7 +137,7 @@ const main = createCountGatedHandler({ itemNumber = Number(itemNumber); if (!Number.isInteger(itemNumber) || itemNumber <= 0) { - const error = "No issue/PR number available"; + const error = target !== "*" && target !== "triggering" ? "Invalid issue/PR number" : "No issue/PR number available"; core.warning(error); return { success: false, error }; } diff --git a/actions/setup/js/replace_label.test.cjs b/actions/setup/js/replace_label.test.cjs index f897bf6236c..05cbe72797d 100644 --- a/actions/setup/js/replace_label.test.cjs +++ b/actions/setup/js/replace_label.test.cjs @@ -143,6 +143,7 @@ describe("replace_label", () => { const result = await handler({ label_to_remove: "in-progress", label_to_add: "done" }, {}); expect(result.success).toBe(false); + expect(result.error).toBe("Invalid issue/PR number"); expect(setLabelsCalls).toHaveLength(0); }); From f2b1714cc212c3a244066b71a09cdf057efaf3ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:19:42 +0000 Subject: [PATCH 7/7] Stabilize checkout runtime order test Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/checkout_runtime_order_test.go | 25 ++++++++++----------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/pkg/workflow/checkout_runtime_order_test.go b/pkg/workflow/checkout_runtime_order_test.go index d02938f3ba4..5be4138c98f 100644 --- a/pkg/workflow/checkout_runtime_order_test.go +++ b/pkg/workflow/checkout_runtime_order_test.go @@ -11,21 +11,20 @@ import ( "github.com/github/gh-aw/pkg/constants" ) -// otlpTelemetryStepNames are compiler-injected observability steps. They are emitted -// for every workflow because the OTLP endpoint defaults to the enterprise -// vars.GH_AW_DEFAULT_OTLP_ENDPOINT / secrets.GH_AW_DEFAULT_OTLP_HEADERS pair, so they -// are not part of the checkout ordering contract exercised by these tests. -var otlpTelemetryStepNames = map[string]bool{ - "Mask OTLP telemetry headers": true, - "Mask OTLP custom attribute values": true, - "Check OTLP telemetry configuration": true, +// compilerInjectedStepNames are not part of the checkout ordering contract exercised +// by these tests. +var compilerInjectedStepNames = map[string]bool{ + "Mask OTLP telemetry headers": true, + "Mask OTLP custom attribute values": true, + "Check OTLP telemetry configuration": true, + "Initialize agent execution evidence": true, } -// filterOTLPTelemetrySteps removes compiler-injected OTLP steps from a step name list. -func filterOTLPTelemetrySteps(names []string) []string { +// filterCompilerInjectedSteps removes unrelated compiler-injected steps from a step name list. +func filterCompilerInjectedSteps(names []string) []string { filtered := make([]string, 0, len(names)) for _, name := range names { - if otlpTelemetryStepNames[name] { + if compilerInjectedStepNames[name] { continue } filtered = append(filtered, name) @@ -141,7 +140,7 @@ steps: } } - stepNames = filterOTLPTelemetrySteps(stepNames) + stepNames = filterCompilerInjectedSteps(stepNames) t.Logf("Found %d steps: %v", len(stepNames), stepNames) @@ -460,7 +459,7 @@ Run node --version to check the Node.js version. } } - stepNames = filterOTLPTelemetrySteps(stepNames) + stepNames = filterCompilerInjectedSteps(stepNames) if len(stepNames) < 4 { t.Fatalf("Expected at least 4 steps, got %d: %v", len(stepNames), stepNames)