Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion actions/setup/js/awf_reflect.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
64 changes: 54 additions & 10 deletions actions/setup/js/check_permissions_utils.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Comment thread
pelikhan marked this conversation as resolved.
const inheritedStandardRole = isCustomRole && STANDARD_ROLES.has(normalizedInheritedRole) ? normalizedInheritedRole : "";
const debugRoleName = normalizedRoleName || "<empty>";
const debugInheritedRole = normalizedInheritedRole || "<empty>";
const debugInheritedStandardRole = inheritedStandardRole || "<empty>";
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) {
Expand Down
119 changes: 119 additions & 0 deletions actions/setup/js/check_permissions_utils.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Comment thread
pelikhan marked this conversation as resolved.
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");
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The two new test cases cover write base permission, but no test covers the read/triage base permission range for custom roles, nor the edge where normalizedRoleName is an empty string (the isCustomRole guard explicitly excludes it).

💡 Suggested additional cases

Add:

  1. Custom role with permission: "read" required by ["read"] — should authorize.
  2. Custom role with permission: "read" required by ["write"] — should reject.
  3. role_name: "" (empty) — should still follow the standard-role path (i.e., not treated as custom).

These cases ensure the isCustomRole guard boundary is tested, not just the happy path.

@copilot please address this.


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='<empty>'");
expect(mockCore.debug).toHaveBeenCalledWith("Repository permission computed roles for 'testuser': effective='Security Champions', custom_role=true, inherited_standard_role='<empty>'");
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" },
Expand Down
4 changes: 3 additions & 1 deletion docs/src/content/docs/reference/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion docs/src/content/docs/reference/triggers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
23 changes: 21 additions & 2 deletions docs/src/content/docs/specs/safe-outputs-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down