From 8928729e77b73768fba1586adcb5c158365a90b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:03:25 +0000 Subject: [PATCH 01/12] Initial plan From 87127292bdcdd4ccd60284380b0565316c9d8bc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:09:28 +0000 Subject: [PATCH 02/12] fix: authorize custom org roles via base permission in checkRepositoryPermission Custom organization repository roles (e.g. "Security Champions") return a non-standard role_name from the GitHub API alongside a base permission (e.g. "write"). The previous logic used role_name exclusively when present, causing these roles to be rejected since their name doesn't match any on.roles enum value. Fix: for standard GitHub roles (admin, maintain, write, triage, read) keep exact role_name matching (preserving #37959's maintain/triage precision). For custom org roles, also check the base permission so that an actor whose custom role confers write is authorized when write is in the required set. Adds two test cases covering: (1) custom role authorized via base permission, (2) custom role rejected when base permission is insufficient. Closes #45536 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/check_permissions_utils.cjs | 12 ++++++-- .../setup/js/check_permissions_utils.test.cjs | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/check_permissions_utils.cjs b/actions/setup/js/check_permissions_utils.cjs index 12cd925dd6e..1ee0d5cf2fa 100644 --- a/actions/setup/js/check_permissions_utils.cjs +++ b/actions/setup/js/check_permissions_utils.cjs @@ -251,11 +251,19 @@ async function checkRepositoryPermission(actor, owner, repo, requiredPermissions const logDetails = normalizedRoleName && normalizedRoleName !== normalizedPermission ? `${normalizedPermission} (role: ${normalizedRoleName})` : normalizedPermission; core.info(`Repository permission level: ${logDetails}`); + // Standard GitHub repository permission levels. Custom org repository roles (e.g. + // "Security Champions") have a role_name that is not one of these — for those, fall + // back to the base permission level so that the actor is not blocked simply because + // their custom role name is not literally listed in on.roles. + const STANDARD_ROLES = new Set(["admin", "maintain", "write", "triage", "read"]); + const isCustomRole = normalizedRoleName !== "" && !STANDARD_ROLES.has(normalizedRoleName); + // Check if user has one of the required permission levels. - // Prefer role_name (API's precise repository role) when present; fall back to permission. + // For standard roles, use role_name (precise: maintain/triage are not collapsed to + // write/read). For custom org roles, also accept a match on the base permission. const hasPermission = requiredPermissions.some(requiredPerm => { const normalizedRequired = requiredPerm === "maintainer" ? "maintain" : requiredPerm; - return normalizedRequired === effectiveRole; + return normalizedRequired === effectiveRole || (isCustomRole && normalizedRequired === normalizedPermission); }); if (hasPermission) { diff --git a/actions/setup/js/check_permissions_utils.test.cjs b/actions/setup/js/check_permissions_utils.test.cjs index 91b8dca8aa6..48f3928e23c 100644 --- a/actions/setup/js/check_permissions_utils.test.cjs +++ b/actions/setup/js/check_permissions_utils.test.cjs @@ -325,6 +325,34 @@ describe("check_permissions_utils", () => { expect(mockCore.warning).toHaveBeenCalledWith("User permission 'maintain' does not meet requirements: write"); }); + it("should authorize custom org role via base permission when base permission matches", async () => { + mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ + data: { permission: "write", role_name: "Security Champions" }, + }); + + const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["admin", "maintainer", "write"]); + + expect(result).toEqual({ + authorized: true, + permission: "Security Champions", + }); + expect(mockCore.info).toHaveBeenCalledWith("✅ User has Security Champions access to repository"); + }); + + it("should reject custom org role when base permission does not match required", async () => { + mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ + data: { permission: "read", role_name: "Security Champions" }, + }); + + const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["admin", "write"]); + + expect(result).toEqual({ + authorized: false, + permission: "Security Champions", + }); + expect(mockCore.warning).toHaveBeenCalledWith("User permission 'Security Champions' does not meet requirements: admin, write"); + }); + it("should check permissions in order and stop at first match", async () => { mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ data: { permission: "write" }, From 05e9d3c9b8011c9f70cf310e6a811880a19614da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:10:16 +0000 Subject: [PATCH 03/12] test: use canonical 'maintain' over 'maintainer' in custom role test case Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/check_permissions_utils.test.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/setup/js/check_permissions_utils.test.cjs b/actions/setup/js/check_permissions_utils.test.cjs index 48f3928e23c..bcdefd2f7d1 100644 --- a/actions/setup/js/check_permissions_utils.test.cjs +++ b/actions/setup/js/check_permissions_utils.test.cjs @@ -330,7 +330,7 @@ describe("check_permissions_utils", () => { data: { permission: "write", role_name: "Security Champions" }, }); - const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["admin", "maintainer", "write"]); + const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["admin", "maintain", "write"]); expect(result).toEqual({ authorized: true, From d2d24f52b60151c5218e9aef43a10285ce414aa6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:24:31 +0000 Subject: [PATCH 04/12] docs: document custom org role base-permission fallback in triggers and glossary Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/glossary.md | 4 +++- docs/src/content/docs/reference/triggers.md | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/reference/glossary.md b/docs/src/content/docs/reference/glossary.md index a4e0a821a21..17253ce4292 100644 --- a/docs/src/content/docs/reference/glossary.md +++ b/docs/src/content/docs/reference/glossary.md @@ -898,13 +898,15 @@ on: ### Role Filtering (`on.roles:`, `on.skip-roles:`) -An authorization control restricting which repository access roles can trigger a workflow. `roles:` is an exact-match allowlist — each value must match the actor's role exactly, with no privilege hierarchy. Defaults to `[admin, maintainer, write]`. `skip-roles:` is the inverse. +An authorization control restricting which repository access roles can trigger a workflow. `roles:` is an exact-match allowlist for standard GitHub roles — each value must match the actor's role exactly, with no privilege hierarchy. Defaults to `[admin, maintainer, write]`. `skip-roles:` is the inverse. Available roles: `admin`, `maintainer`/`maintain`, `write`, `triage`, `read`, `all`. Workflows with unsafe triggers (`push`, `issues`, `pull_request`) automatically enforce role checks. > [!WARNING] > `roles` is not a privilege threshold. Setting `roles: [write]` rejects admins and maintainers because `admin !== write`. To accept all typical contributors, list every role explicitly. +Actors assigned a **custom organization repository role** (e.g. `Security Champions`) are authorized via the base permission that role confers — not the custom role name. A user with a custom role whose base is `write` is authorized whenever `write` is in the required set. + See [Triggers Reference](/gh-aw/reference/triggers/). ```aw wrap diff --git a/docs/src/content/docs/reference/triggers.md b/docs/src/content/docs/reference/triggers.md index 0434c97632c..76a786ae758 100644 --- a/docs/src/content/docs/reference/triggers.md +++ b/docs/src/content/docs/reference/triggers.md @@ -392,7 +392,7 @@ For conditions based on GitHub search results, use [`skip-if-match:`](#skip-if-m ### Filtering by Repository Access Roles (`on.roles:`, `on.skip-roles:`) -Controls who can trigger agentic workflows using an **exact-match allowlist** — each role is matched literally against the actor's repository role with no privilege hierarchy. Defaults to `[admin, maintainer, write]`. Use `skip-roles:` to exempt team members from checks that should only apply to external contributors. +Controls who can trigger agentic workflows using an **exact-match allowlist** against the actor's repository role. Defaults to `[admin, maintainer, write]`. Use `skip-roles:` to exempt team members from checks that should only apply to external contributors. ```yaml wrap on: @@ -408,6 +408,10 @@ on: Available roles: `admin`, `maintainer`/`maintain`, `write`, `triage`, `read`, `all`. Workflows with unsafe triggers (`push`, `issues`, `pull_request`) automatically enforce permission checks. Failed checks cancel the workflow with a warning. +#### Custom organization repository roles + +GitHub organizations can define [custom repository roles](https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/about-custom-repository-roles) with a base permission level (e.g. `write`). Actors whose access comes from a custom role (e.g. `Security Champions`) are authorized against the **base permission** of that role — the custom role name itself cannot appear in `on.roles:`. For example, a user with the custom role `Security Champions` (base: `write`) will be authorized when the required roles include `write`. + ### Filtering by Bot (`on.bots:`, `on.skip-bots:`) Configure which GitHub bot accounts can trigger workflows — useful for allowing specific automation bots while maintaining security controls. Use `skip-bots:` for the inverse: From 54f16588cfe21b6e370ae39d469e716dbb2560d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:49:25 +0000 Subject: [PATCH 05/12] fix: use inherited custom role metadata for auth Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/check_permissions_utils.cjs | 15 ++++++++---- .../setup/js/check_permissions_utils.test.cjs | 24 +++++++++++++++---- docs/src/content/docs/reference/glossary.md | 2 +- docs/src/content/docs/reference/triggers.md | 2 +- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/actions/setup/js/check_permissions_utils.cjs b/actions/setup/js/check_permissions_utils.cjs index 1ee0d5cf2fa..df7e85c6cd4 100644 --- a/actions/setup/js/check_permissions_utils.cjs +++ b/actions/setup/js/check_permissions_utils.cjs @@ -245,25 +245,30 @@ async function checkRepositoryPermission(actor, owner, repo, requiredPermissions const permission = repoPermission.data.permission; const rawRoleName = repoPermission.data.role_name; const roleName = rawRoleName == null ? "" : typeof rawRoleName === "string" ? rawRoleName : ""; + const rawInheritedRole = repoPermission.data.inherited_role; + const inheritedRole = rawInheritedRole == null ? "" : typeof rawInheritedRole === "string" ? rawInheritedRole : ""; const normalizedRoleName = roleName === "maintainer" ? "maintain" : roleName; const normalizedPermission = permission === "maintainer" ? "maintain" : permission; + const normalizedInheritedRole = inheritedRole === "maintainer" ? "maintain" : inheritedRole; const effectiveRole = normalizedRoleName || normalizedPermission; const logDetails = normalizedRoleName && normalizedRoleName !== normalizedPermission ? `${normalizedPermission} (role: ${normalizedRoleName})` : normalizedPermission; core.info(`Repository permission level: ${logDetails}`); // Standard GitHub repository permission levels. Custom org repository roles (e.g. - // "Security Champions") have a role_name that is not one of these — for those, fall - // back to the base permission level so that the actor is not blocked simply because - // their custom role name is not literally listed in on.roles. + // "Security Champions") have a role_name that is not one of these — for those, use + // the inherited standard role from GitHub's custom-role metadata so the actor is not + // blocked simply because their custom role name is not literally listed in on.roles. const STANDARD_ROLES = new Set(["admin", "maintain", "write", "triage", "read"]); const isCustomRole = normalizedRoleName !== "" && !STANDARD_ROLES.has(normalizedRoleName); + const inheritedStandardRole = isCustomRole && STANDARD_ROLES.has(normalizedInheritedRole) ? normalizedInheritedRole : ""; // Check if user has one of the required permission levels. // For standard roles, use role_name (precise: maintain/triage are not collapsed to - // write/read). For custom org roles, also accept a match on the base permission. + // write/read). For custom org roles, only fall back to the inherited standard role + // from custom-role metadata; fail closed if GitHub does not provide it. const hasPermission = requiredPermissions.some(requiredPerm => { const normalizedRequired = requiredPerm === "maintainer" ? "maintain" : requiredPerm; - return normalizedRequired === effectiveRole || (isCustomRole && normalizedRequired === normalizedPermission); + return normalizedRequired === effectiveRole || normalizedRequired === inheritedStandardRole; }); if (hasPermission) { diff --git a/actions/setup/js/check_permissions_utils.test.cjs b/actions/setup/js/check_permissions_utils.test.cjs index bcdefd2f7d1..f50ee3c2296 100644 --- a/actions/setup/js/check_permissions_utils.test.cjs +++ b/actions/setup/js/check_permissions_utils.test.cjs @@ -327,7 +327,7 @@ describe("check_permissions_utils", () => { it("should authorize custom org role via base permission when base permission matches", async () => { mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ - data: { permission: "write", role_name: "Security Champions" }, + data: { permission: "write", role_name: "Security Champions", inherited_role: "write" }, }); const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["admin", "maintain", "write"]); @@ -339,18 +339,32 @@ describe("check_permissions_utils", () => { expect(mockCore.info).toHaveBeenCalledWith("✅ User has Security Champions access to repository"); }); - it("should reject custom org role when base permission does not match required", async () => { + it("should reject maintain-based custom org role when only write is required", async () => { mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ - data: { permission: "read", role_name: "Security Champions" }, + data: { permission: "write", role_name: "Security Champions", inherited_role: "maintain" }, }); - const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["admin", "write"]); + const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["write"]); + + expect(result).toEqual({ + authorized: false, + permission: "Security Champions", + }); + expect(mockCore.warning).toHaveBeenCalledWith("User permission 'Security Champions' does not meet requirements: write"); + }); + + it("should fail closed for custom org role when inherited role metadata is unavailable", async () => { + mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ + data: { permission: "write", role_name: "Security Champions" }, + }); + + const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["write"]); expect(result).toEqual({ authorized: false, permission: "Security Champions", }); - expect(mockCore.warning).toHaveBeenCalledWith("User permission 'Security Champions' does not meet requirements: admin, write"); + expect(mockCore.warning).toHaveBeenCalledWith("User permission 'Security Champions' does not meet requirements: write"); }); it("should check permissions in order and stop at first match", async () => { diff --git a/docs/src/content/docs/reference/glossary.md b/docs/src/content/docs/reference/glossary.md index 17253ce4292..a90c88fbea8 100644 --- a/docs/src/content/docs/reference/glossary.md +++ b/docs/src/content/docs/reference/glossary.md @@ -905,7 +905,7 @@ Available roles: `admin`, `maintainer`/`maintain`, `write`, `triage`, `read`, `a > [!WARNING] > `roles` is not a privilege threshold. Setting `roles: [write]` rejects admins and maintainers because `admin !== write`. To accept all typical contributors, list every role explicitly. -Actors assigned a **custom organization repository role** (e.g. `Security Champions`) are authorized via the base permission that role confers — not the custom role name. A user with a custom role whose base is `write` is authorized whenever `write` is in the required set. +Actors assigned a **custom organization repository role** (e.g. `Security Champions`) are authorized via the inherited standard role that GitHub reports for that custom role — not the custom role name. A user with a custom role inherited from `write` is authorized whenever `write` is in the required set, while a custom role inherited from `maintain` is still rejected by `roles: [write]`. See [Triggers Reference](/gh-aw/reference/triggers/). diff --git a/docs/src/content/docs/reference/triggers.md b/docs/src/content/docs/reference/triggers.md index 76a786ae758..deda316d31a 100644 --- a/docs/src/content/docs/reference/triggers.md +++ b/docs/src/content/docs/reference/triggers.md @@ -410,7 +410,7 @@ Available roles: `admin`, `maintainer`/`maintain`, `write`, `triage`, `read`, `a #### Custom organization repository roles -GitHub organizations can define [custom repository roles](https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/about-custom-repository-roles) with a base permission level (e.g. `write`). Actors whose access comes from a custom role (e.g. `Security Champions`) are authorized against the **base permission** of that role — the custom role name itself cannot appear in `on.roles:`. For example, a user with the custom role `Security Champions` (base: `write`) will be authorized when the required roles include `write`. +GitHub organizations can define [custom repository roles](https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/about-custom-repository-roles) with an inherited standard role (for example `write` or `maintain`). Actors whose access comes from a custom role (e.g. `Security Champions`) are authorized against that **inherited standard role** — the custom role name itself cannot appear in `on.roles:`. For example, a user with the custom role `Security Champions` (inherited role: `write`) will be authorized when the required roles include `write`, while a custom role inherited from `maintain` will still be rejected by `roles: [write]`. ### Filtering by Bot (`on.bots:`, `on.skip-bots:`) From 152d9e58802a17ec929ba7e322bf02df48aca142 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:12:03 +0000 Subject: [PATCH 06/12] test: cover inherited custom role boundaries Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/check_permissions_utils.cjs | 11 +-- .../setup/js/check_permissions_utils.test.cjs | 70 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/check_permissions_utils.cjs b/actions/setup/js/check_permissions_utils.cjs index df7e85c6cd4..a8dd941a60f 100644 --- a/actions/setup/js/check_permissions_utils.cjs +++ b/actions/setup/js/check_permissions_utils.cjs @@ -3,6 +3,8 @@ const { getErrorMessage } = require("./error_helpers.cjs"); +const STANDARD_ROLES = new Set(["admin", "maintain", "write", "triage", "read"]); + /** * Shared utility for repository permission validation * Used by both check_permissions.cjs and check_membership.cjs @@ -242,10 +244,12 @@ async function checkRepositoryPermission(actor, owner, repo, requiredPermissions username: actor, }); - const permission = repoPermission.data.permission; - const rawRoleName = repoPermission.data.role_name; + /** @type {{ permission: string, role_name?: unknown, inherited_role?: unknown }} */ + const repoPermissionData = repoPermission.data; + const permission = repoPermissionData.permission; + const rawRoleName = repoPermissionData.role_name; const roleName = rawRoleName == null ? "" : typeof rawRoleName === "string" ? rawRoleName : ""; - const rawInheritedRole = repoPermission.data.inherited_role; + const rawInheritedRole = repoPermissionData.inherited_role; const inheritedRole = rawInheritedRole == null ? "" : typeof rawInheritedRole === "string" ? rawInheritedRole : ""; const normalizedRoleName = roleName === "maintainer" ? "maintain" : roleName; const normalizedPermission = permission === "maintainer" ? "maintain" : permission; @@ -258,7 +262,6 @@ async function checkRepositoryPermission(actor, owner, repo, requiredPermissions // "Security Champions") have a role_name that is not one of these — for those, use // the inherited standard role from GitHub's custom-role metadata so the actor is not // blocked simply because their custom role name is not literally listed in on.roles. - const STANDARD_ROLES = new Set(["admin", "maintain", "write", "triage", "read"]); const isCustomRole = normalizedRoleName !== "" && !STANDARD_ROLES.has(normalizedRoleName); const inheritedStandardRole = isCustomRole && STANDARD_ROLES.has(normalizedInheritedRole) ? normalizedInheritedRole : ""; diff --git a/actions/setup/js/check_permissions_utils.test.cjs b/actions/setup/js/check_permissions_utils.test.cjs index f50ee3c2296..83940fd6112 100644 --- a/actions/setup/js/check_permissions_utils.test.cjs +++ b/actions/setup/js/check_permissions_utils.test.cjs @@ -353,6 +353,76 @@ describe("check_permissions_utils", () => { expect(mockCore.warning).toHaveBeenCalledWith("User permission 'Security Champions' does not meet requirements: write"); }); + it("should authorize maintain-based custom org role when maintain is required", async () => { + mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ + data: { permission: "write", role_name: "Security Champions", inherited_role: "maintain" }, + }); + + const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["maintain"]); + + expect(result).toEqual({ + authorized: true, + permission: "Security Champions", + }); + expect(mockCore.info).toHaveBeenCalledWith("✅ User has Security Champions access to repository"); + }); + + it("should authorize read-based custom org role when read is required", async () => { + mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ + data: { permission: "read", role_name: "Security Champions", inherited_role: "read" }, + }); + + const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["read"]); + + expect(result).toEqual({ + authorized: true, + permission: "Security Champions", + }); + expect(mockCore.info).toHaveBeenCalledWith("✅ User has Security Champions access to repository"); + }); + + it("should reject read-based custom org role when required permission does not match", async () => { + mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ + data: { permission: "read", role_name: "Security Champions", inherited_role: "read" }, + }); + + const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["write"]); + + expect(result).toEqual({ + authorized: false, + permission: "Security Champions", + }); + expect(mockCore.warning).toHaveBeenCalledWith("User permission 'Security Champions' does not meet requirements: write"); + }); + + it("should authorize when required permissions include the exact custom role name", async () => { + mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ + data: { permission: "write", role_name: "Security Champions", inherited_role: "maintain" }, + }); + + const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["Security Champions"]); + + expect(result).toEqual({ + authorized: true, + permission: "Security Champions", + }); + expect(mockCore.info).toHaveBeenCalledWith("✅ User has Security Champions access to repository"); + }); + + it("should not treat an empty role_name as a custom org role", async () => { + mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ + data: { permission: "write", role_name: "", inherited_role: "maintain" }, + }); + + const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["maintain"]); + + expect(result).toEqual({ + authorized: false, + permission: "write", + }); + expect(mockCore.warning).toHaveBeenCalledWith("User permission 'write' does not meet requirements: maintain"); + }); + it("should fail closed for custom org role when inherited role metadata is unavailable", async () => { mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ data: { permission: "write", role_name: "Security Champions" }, From b3c08646d1f78c87a510bdfe4eed2df2923039f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:34:02 +0000 Subject: [PATCH 07/12] fix: correct reflectData JSDoc narrowing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/awf_reflect.cjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/awf_reflect.cjs b/actions/setup/js/awf_reflect.cjs index b8f01ece2d6..c27227383a5 100644 --- a/actions/setup/js/awf_reflect.cjs +++ b/actions/setup/js/awf_reflect.cjs @@ -542,7 +542,8 @@ function resolveProviderEndpointFromReflect(options) { const logger = (options && options.logger) || DEFAULT_REFLECT_LOGGER; const provider = normalizeReflectProviderName(options?.provider, "openai"); const reflectData = options?.reflectData; - const reflectRecord = reflectData && typeof reflectData === "object" ? /** @type {{ endpoints?: unknown }} */ reflectData : null; + /** @type {{ endpoints?: unknown } | null} */ + const reflectRecord = reflectData && typeof reflectData === "object" ? reflectData : null; const endpointCandidates = Array.isArray(reflectRecord?.endpoints) ? reflectRecord.endpoints : []; const endpoints = endpointCandidates.filter(/** @param {any} ep */ ep => ep && ep.configured === true); if (endpoints.length === 0) { From 7f369786386c9303d62092222ef7609731124f28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:07:51 +0000 Subject: [PATCH 08/12] docs: clarify safe outputs replay auth for custom roles Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../content/docs/reference/safe-outputs.md | 2 +- .../docs/specs/safe-outputs-specification.md | 23 +++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index ae616bb0a6e..27ce9b6d562 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -1891,7 +1891,7 @@ To replay safe outputs: - Run ID only: `12345` 6. Click **Run workflow**. -The `apply_safe_outputs` job downloads the `agent_output.json` artifact from the specified run and applies all safe outputs as if the original run had completed successfully. The job requires admin or maintainer permissions. +The `apply_safe_outputs` job downloads the `agent_output.json` artifact from the specified run and applies all safe outputs as if the original run had completed successfully. Authorization requires exact `admin` or `maintain` repository access. For custom organization repository roles, gh-aw authorizes against the inherited standard role metadata reported by GitHub rather than the custom role name. If that inherited standard role cannot be resolved, the replay request is rejected. > [!TIP] > Find the run URL by opening the failed or cancelled run in the **Actions** tab — the URL in your browser's address bar is the run URL. diff --git a/docs/src/content/docs/specs/safe-outputs-specification.md b/docs/src/content/docs/specs/safe-outputs-specification.md index c4956a6da68..bab0861dc68 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.24.0 +**Version**: 1.25.0 **Status**: Working Draft -**Publication Date**: 2026-06-13 +**Publication Date**: 2026-07-15 **Editor**: GitHub Agentic Workflows Team **This Version**: [safe-outputs-specification](/gh-aw/specs/safe-outputs-specification/) **Latest Published Version**: This document @@ -4646,6 +4646,19 @@ This section defines required behavior for unusual or boundary conditions. *Rationale*: Deterministic behavior prevents confusion. +**Replay Authorization with Custom Repository Roles** + +*Scenario*: A user manually replays safe outputs through the Agentic Maintenance workflow in a repository that uses custom organization repository roles. + +*Behavior*: + +- Implementations MUST authorize replay only for actors whose effective standard repository role is exactly `admin` or `maintain`. +- When GitHub reports a custom repository role name, implementations MUST resolve authorization from the inherited standard role metadata supplied by GitHub, not from the custom role name string. +- Implementations MUST NOT infer exact authorization from the legacy `permission` bucket alone, because that bucket may collapse `maintain` to `write` and `triage` to `read`. +- If the inherited standard role for a custom repository role cannot be resolved, the replay request MUST be rejected. + +*Rationale*: This preserves exact-match authorization semantics for standard roles while still permitting custom organization roles that inherit an authorized standard role. + --- ## 11. Cache Memory Integrity @@ -5231,6 +5244,12 @@ 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.25.0** (2026-07-15): + +- **Added**: Replay authorization requirements in Section 10.6 for Agentic Maintenance `safe_outputs` replays in repositories using custom organization repository roles. +- **Specified**: Replay authorization MUST use exact `admin` or `maintain` standard roles and, for custom roles, the inherited standard-role metadata reported by GitHub rather than the custom role name or the collapsed legacy `permission` bucket. +- **Updated**: Publication metadata to 1.25.0. + **Version 1.24.0** (2026-06-13): - **Changed**: Default value of the `discussions` field on `add-comment` inverted from `true` to `false`. The `discussions:write` permission is now opt-in. Set `discussions: true` to comment on discussions; omitting the field no longer requests `discussions:write`. The `hide-comment.discussions` default remains `true`. From 85758a14e396da2f07c2b3e6744ad7409b97150b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:12:02 +0000 Subject: [PATCH 09/12] chore: add permission resolution debug logs Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/check_permissions_utils.cjs | 25 ++++++++++++++++++- .../setup/js/check_permissions_utils.test.cjs | 9 +++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/check_permissions_utils.cjs b/actions/setup/js/check_permissions_utils.cjs index a8dd941a60f..ed31883b6a3 100644 --- a/actions/setup/js/check_permissions_utils.cjs +++ b/actions/setup/js/check_permissions_utils.cjs @@ -264,21 +264,44 @@ async function checkRepositoryPermission(actor, owner, repo, requiredPermissions // blocked simply because their custom role name is not literally listed in on.roles. const isCustomRole = normalizedRoleName !== "" && !STANDARD_ROLES.has(normalizedRoleName); const inheritedStandardRole = isCustomRole && STANDARD_ROLES.has(normalizedInheritedRole) ? normalizedInheritedRole : ""; + const debugRoleName = normalizedRoleName || ""; + const debugInheritedRole = normalizedInheritedRole || ""; + const debugInheritedStandardRole = inheritedStandardRole || ""; + core.debug?.( + `Repository permission resolution for '${actor}': permission='${normalizedPermission}', role='${debugRoleName}', inherited='${debugInheritedRole}', effective='${effectiveRole}', custom_role=${isCustomRole}, inherited_standard_role='${debugInheritedStandardRole}'` + ); + if (isCustomRole && inheritedStandardRole === "") { + core.debug?.(`Repository permission fallback unavailable for custom role '${normalizedRoleName}' because GitHub did not provide an inherited standard role`); + } // Check if user has one of the required permission levels. // For standard roles, use role_name (precise: maintain/triage are not collapsed to // write/read). For custom org roles, only fall back to the inherited standard role // from custom-role metadata; fail closed if GitHub does not provide it. + let matchedPermission = ""; + let matchSource = ""; const hasPermission = requiredPermissions.some(requiredPerm => { const normalizedRequired = requiredPerm === "maintainer" ? "maintain" : requiredPerm; - return normalizedRequired === effectiveRole || normalizedRequired === inheritedStandardRole; + if (normalizedRequired === effectiveRole) { + matchedPermission = normalizedRequired; + matchSource = "effective-role"; + return true; + } + if (normalizedRequired === inheritedStandardRole) { + matchedPermission = normalizedRequired; + matchSource = "inherited-standard-role"; + return true; + } + return false; }); if (hasPermission) { + core.debug?.(`Repository permission matched required role '${matchedPermission}' via ${matchSource}`); core.info(`✅ User has ${effectiveRole} access to repository`); return { authorized: true, permission: effectiveRole }; } + core.debug?.(`Repository permission did not match required roles: ${requiredPermissions.join(", ")}`); core.warning(`User permission '${effectiveRole}' does not meet requirements: ${requiredPermissions.join(", ")}`); return { authorized: false, permission: effectiveRole }; } catch (repoError) { diff --git a/actions/setup/js/check_permissions_utils.test.cjs b/actions/setup/js/check_permissions_utils.test.cjs index 83940fd6112..e689e7052dc 100644 --- a/actions/setup/js/check_permissions_utils.test.cjs +++ b/actions/setup/js/check_permissions_utils.test.cjs @@ -336,6 +336,10 @@ describe("check_permissions_utils", () => { authorized: true, permission: "Security Champions", }); + expect(mockCore.debug).toHaveBeenCalledWith( + "Repository permission resolution for 'testuser': permission='write', role='Security Champions', inherited='write', effective='Security Champions', custom_role=true, inherited_standard_role='write'" + ); + expect(mockCore.debug).toHaveBeenCalledWith("Repository permission matched required role 'write' via inherited-standard-role"); expect(mockCore.info).toHaveBeenCalledWith("✅ User has Security Champions access to repository"); }); @@ -434,6 +438,11 @@ describe("check_permissions_utils", () => { authorized: false, permission: "Security Champions", }); + expect(mockCore.debug).toHaveBeenCalledWith( + "Repository permission resolution for 'testuser': permission='write', role='Security Champions', inherited='', effective='Security Champions', custom_role=true, inherited_standard_role=''" + ); + expect(mockCore.debug).toHaveBeenCalledWith("Repository permission fallback unavailable for custom role 'Security Champions' because GitHub did not provide an inherited standard role"); + expect(mockCore.debug).toHaveBeenCalledWith("Repository permission did not match required roles: write"); expect(mockCore.warning).toHaveBeenCalledWith("User permission 'Security Champions' does not meet requirements: write"); }); From 8148beb17ee91ddac01e260dba9ccaec6543579b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:15:59 +0000 Subject: [PATCH 10/12] refactor: split permission debug logging Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/check_permissions_utils.cjs | 13 ++++++------- actions/setup/js/check_permissions_utils.test.cjs | 10 ++++------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/actions/setup/js/check_permissions_utils.cjs b/actions/setup/js/check_permissions_utils.cjs index ed31883b6a3..856b468b304 100644 --- a/actions/setup/js/check_permissions_utils.cjs +++ b/actions/setup/js/check_permissions_utils.cjs @@ -267,9 +267,8 @@ async function checkRepositoryPermission(actor, owner, repo, requiredPermissions const debugRoleName = normalizedRoleName || ""; const debugInheritedRole = normalizedInheritedRole || ""; const debugInheritedStandardRole = inheritedStandardRole || ""; - core.debug?.( - `Repository permission resolution for '${actor}': permission='${normalizedPermission}', role='${debugRoleName}', inherited='${debugInheritedRole}', effective='${effectiveRole}', custom_role=${isCustomRole}, inherited_standard_role='${debugInheritedStandardRole}'` - ); + core.debug?.(`Repository permission API fields for '${actor}': permission='${normalizedPermission}', role='${debugRoleName}', inherited='${debugInheritedRole}'`); + core.debug?.(`Repository permission computed roles for '${actor}': effective='${effectiveRole}', custom_role=${isCustomRole}, inherited_standard_role='${debugInheritedStandardRole}'`); if (isCustomRole && inheritedStandardRole === "") { core.debug?.(`Repository permission fallback unavailable for custom role '${normalizedRoleName}' because GitHub did not provide an inherited standard role`); } @@ -279,24 +278,24 @@ async function checkRepositoryPermission(actor, owner, repo, requiredPermissions // write/read). For custom org roles, only fall back to the inherited standard role // from custom-role metadata; fail closed if GitHub does not provide it. let matchedPermission = ""; - let matchSource = ""; + let roleMatchType = ""; const hasPermission = requiredPermissions.some(requiredPerm => { const normalizedRequired = requiredPerm === "maintainer" ? "maintain" : requiredPerm; if (normalizedRequired === effectiveRole) { matchedPermission = normalizedRequired; - matchSource = "effective-role"; + roleMatchType = "effective-role"; return true; } if (normalizedRequired === inheritedStandardRole) { matchedPermission = normalizedRequired; - matchSource = "inherited-standard-role"; + roleMatchType = "inherited-standard-role"; return true; } return false; }); if (hasPermission) { - core.debug?.(`Repository permission matched required role '${matchedPermission}' via ${matchSource}`); + core.debug?.(`Repository permission matched required role '${matchedPermission}' via ${roleMatchType}`); core.info(`✅ User has ${effectiveRole} access to repository`); return { authorized: true, permission: effectiveRole }; } diff --git a/actions/setup/js/check_permissions_utils.test.cjs b/actions/setup/js/check_permissions_utils.test.cjs index e689e7052dc..13b92e0d2f0 100644 --- a/actions/setup/js/check_permissions_utils.test.cjs +++ b/actions/setup/js/check_permissions_utils.test.cjs @@ -336,9 +336,8 @@ describe("check_permissions_utils", () => { authorized: true, permission: "Security Champions", }); - expect(mockCore.debug).toHaveBeenCalledWith( - "Repository permission resolution for 'testuser': permission='write', role='Security Champions', inherited='write', effective='Security Champions', custom_role=true, inherited_standard_role='write'" - ); + expect(mockCore.debug).toHaveBeenCalledWith("Repository permission API fields for 'testuser': permission='write', role='Security Champions', inherited='write'"); + expect(mockCore.debug).toHaveBeenCalledWith("Repository permission computed roles for 'testuser': effective='Security Champions', custom_role=true, inherited_standard_role='write'"); expect(mockCore.debug).toHaveBeenCalledWith("Repository permission matched required role 'write' via inherited-standard-role"); expect(mockCore.info).toHaveBeenCalledWith("✅ User has Security Champions access to repository"); }); @@ -438,9 +437,8 @@ describe("check_permissions_utils", () => { authorized: false, permission: "Security Champions", }); - expect(mockCore.debug).toHaveBeenCalledWith( - "Repository permission resolution for 'testuser': permission='write', role='Security Champions', inherited='', effective='Security Champions', custom_role=true, inherited_standard_role=''" - ); + expect(mockCore.debug).toHaveBeenCalledWith("Repository permission API fields for 'testuser': permission='write', role='Security Champions', inherited=''"); + expect(mockCore.debug).toHaveBeenCalledWith("Repository permission computed roles for 'testuser': effective='Security Champions', custom_role=true, inherited_standard_role=''"); expect(mockCore.debug).toHaveBeenCalledWith("Repository permission fallback unavailable for custom role 'Security Champions' because GitHub did not provide an inherited standard role"); expect(mockCore.debug).toHaveBeenCalledWith("Repository permission did not match required roles: write"); expect(mockCore.warning).toHaveBeenCalledWith("User permission 'Security Champions' does not meet requirements: write"); From e3cc02eb27c715389fd9f5819d26bb397893c132 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:19:47 +0000 Subject: [PATCH 11/12] refactor: narrow permission match state Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/check_permissions_utils.cjs | 23 +++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/actions/setup/js/check_permissions_utils.cjs b/actions/setup/js/check_permissions_utils.cjs index 856b468b304..fa277067969 100644 --- a/actions/setup/js/check_permissions_utils.cjs +++ b/actions/setup/js/check_permissions_utils.cjs @@ -277,25 +277,22 @@ async function checkRepositoryPermission(actor, owner, repo, requiredPermissions // For standard roles, use role_name (precise: maintain/triage are not collapsed to // write/read). For custom org roles, only fall back to the inherited standard role // from custom-role metadata; fail closed if GitHub does not provide it. - let matchedPermission = ""; - let roleMatchType = ""; - const hasPermission = requiredPermissions.some(requiredPerm => { + /** @type {{ permission: string, roleMatchType: string }|null} */ + let matchedRole = null; + for (const requiredPerm of requiredPermissions) { const normalizedRequired = requiredPerm === "maintainer" ? "maintain" : requiredPerm; if (normalizedRequired === effectiveRole) { - matchedPermission = normalizedRequired; - roleMatchType = "effective-role"; - return true; + matchedRole = { permission: normalizedRequired, roleMatchType: "effective-role" }; + break; } if (normalizedRequired === inheritedStandardRole) { - matchedPermission = normalizedRequired; - roleMatchType = "inherited-standard-role"; - return true; + matchedRole = { permission: normalizedRequired, roleMatchType: "inherited-standard-role" }; + break; } - return false; - }); + } - if (hasPermission) { - core.debug?.(`Repository permission matched required role '${matchedPermission}' via ${roleMatchType}`); + if (matchedRole) { + core.debug?.(`Repository permission matched required role '${matchedRole.permission}' via ${matchedRole.roleMatchType}`); core.info(`✅ User has ${effectiveRole} access to repository`); return { authorized: true, permission: effectiveRole }; } From 9224d6c602b3c640d45e079167a283a7eb77c422 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:23:39 +0000 Subject: [PATCH 12/12] refactor: reuse role normalization helper Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/check_permissions_utils.cjs | 27 +++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/actions/setup/js/check_permissions_utils.cjs b/actions/setup/js/check_permissions_utils.cjs index fa277067969..e052d1eb0e2 100644 --- a/actions/setup/js/check_permissions_utils.cjs +++ b/actions/setup/js/check_permissions_utils.cjs @@ -5,6 +5,15 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const STANDARD_ROLES = new Set(["admin", "maintain", "write", "triage", "read"]); +/** + * Normalize GitHub permission/role aliases to the canonical values used by on.roles. + * @param {string} role + * @returns {string} + */ +function normalizeRoleName(role) { + return role === "maintainer" ? "maintain" : role; +} + /** * Shared utility for repository permission validation * Used by both check_permissions.cjs and check_membership.cjs @@ -251,9 +260,9 @@ async function checkRepositoryPermission(actor, owner, repo, requiredPermissions const roleName = rawRoleName == null ? "" : typeof rawRoleName === "string" ? rawRoleName : ""; const rawInheritedRole = repoPermissionData.inherited_role; const inheritedRole = rawInheritedRole == null ? "" : typeof rawInheritedRole === "string" ? rawInheritedRole : ""; - const normalizedRoleName = roleName === "maintainer" ? "maintain" : roleName; - const normalizedPermission = permission === "maintainer" ? "maintain" : permission; - const normalizedInheritedRole = inheritedRole === "maintainer" ? "maintain" : inheritedRole; + const normalizedRoleName = normalizeRoleName(roleName); + const normalizedPermission = normalizeRoleName(permission); + const normalizedInheritedRole = normalizeRoleName(inheritedRole); const effectiveRole = normalizedRoleName || normalizedPermission; const logDetails = normalizedRoleName && normalizedRoleName !== normalizedPermission ? `${normalizedPermission} (role: ${normalizedRoleName})` : normalizedPermission; core.info(`Repository permission level: ${logDetails}`); @@ -278,21 +287,21 @@ async function checkRepositoryPermission(actor, owner, repo, requiredPermissions // write/read). For custom org roles, only fall back to the inherited standard role // from custom-role metadata; fail closed if GitHub does not provide it. /** @type {{ permission: string, roleMatchType: string }|null} */ - let matchedRole = null; + let permissionMatch = null; for (const requiredPerm of requiredPermissions) { - const normalizedRequired = requiredPerm === "maintainer" ? "maintain" : requiredPerm; + const normalizedRequired = normalizeRoleName(requiredPerm); if (normalizedRequired === effectiveRole) { - matchedRole = { permission: normalizedRequired, roleMatchType: "effective-role" }; + permissionMatch = { permission: normalizedRequired, roleMatchType: "effective-role" }; break; } if (normalizedRequired === inheritedStandardRole) { - matchedRole = { permission: normalizedRequired, roleMatchType: "inherited-standard-role" }; + permissionMatch = { permission: normalizedRequired, roleMatchType: "inherited-standard-role" }; break; } } - if (matchedRole) { - core.debug?.(`Repository permission matched required role '${matchedRole.permission}' via ${matchedRole.roleMatchType}`); + if (permissionMatch) { + core.debug?.(`Repository permission matched required role '${permissionMatch.permission}' via ${permissionMatch.roleMatchType}`); core.info(`✅ User has ${effectiveRole} access to repository`); return { authorized: true, permission: effectiveRole }; }