diff --git a/actions/setup/js/awf_reflect.cjs b/actions/setup/js/awf_reflect.cjs index e11adf1bbf5..590bd4d950c 100644 --- a/actions/setup/js/awf_reflect.cjs +++ b/actions/setup/js/awf_reflect.cjs @@ -558,7 +558,7 @@ function resolveProviderEndpointFromReflect(options) { const logger = (options && options.logger) || DEFAULT_REFLECT_LOGGER; const provider = normalizeReflectProviderName(options?.provider, "openai"); const reflectData = options?.reflectData; - /** @type {({ endpoints?: unknown } | 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); diff --git a/actions/setup/js/check_permissions_utils.cjs b/actions/setup/js/check_permissions_utils.cjs index 12cd925dd6e..e052d1eb0e2 100644 --- a/actions/setup/js/check_permissions_utils.cjs +++ b/actions/setup/js/check_permissions_utils.cjs @@ -3,6 +3,17 @@ 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 @@ -242,27 +253,60 @@ 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 normalizedRoleName = roleName === "maintainer" ? "maintain" : roleName; - const normalizedPermission = permission === "maintainer" ? "maintain" : permission; + const rawInheritedRole = repoPermissionData.inherited_role; + const inheritedRole = rawInheritedRole == null ? "" : typeof rawInheritedRole === "string" ? rawInheritedRole : ""; + 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}`); + // 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, 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 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 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`); + } + // Check if user has one of the required permission levels. - // Prefer role_name (API's precise repository role) when present; fall back to permission. - const hasPermission = requiredPermissions.some(requiredPerm => { - const normalizedRequired = requiredPerm === "maintainer" ? "maintain" : requiredPerm; - return normalizedRequired === effectiveRole; - }); + // 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. + /** @type {{ permission: string, roleMatchType: string }|null} */ + let permissionMatch = null; + for (const requiredPerm of requiredPermissions) { + const normalizedRequired = normalizeRoleName(requiredPerm); + if (normalizedRequired === effectiveRole) { + permissionMatch = { permission: normalizedRequired, roleMatchType: "effective-role" }; + break; + } + if (normalizedRequired === inheritedStandardRole) { + permissionMatch = { permission: normalizedRequired, roleMatchType: "inherited-standard-role" }; + break; + } + } - if (hasPermission) { + 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 }; } + 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 91b8dca8aa6..13b92e0d2f0 100644 --- a/actions/setup/js/check_permissions_utils.test.cjs +++ b/actions/setup/js/check_permissions_utils.test.cjs @@ -325,6 +325,125 @@ 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", inherited_role: "write" }, + }); + + const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["admin", "maintain", "write"]); + + expect(result).toEqual({ + authorized: true, + permission: "Security Champions", + }); + 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"); + }); + + it("should reject maintain-based custom org role when only write 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", ["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 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" }, + }); + + const result = await checkRepositoryPermission("testuser", "testowner", "testrepo", ["write"]); + + expect(result).toEqual({ + authorized: false, + permission: "Security Champions", + }); + 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"); + }); + it("should check permissions in order and stop at first match", async () => { mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ data: { permission: "write" }, diff --git a/docs/src/content/docs/reference/glossary.md b/docs/src/content/docs/reference/glossary.md index a4e0a821a21..a90c88fbea8 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 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/). ```aw wrap 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/reference/triggers.md b/docs/src/content/docs/reference/triggers.md index 0434c97632c..deda316d31a 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 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:`) Configure which GitHub bot accounts can trigger workflows — useful for allowing specific automation bots while maintaining security controls. Use `skip-bots:` for the inverse: 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`.