Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Authorize canonical Copilot bot aliases safely
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
  • Loading branch information
Copilot and gh-aw-bot authored Oct 9, 2026
commit fb33cbd2b307587adcbb2cf2a135573dbca49877
24 changes: 16 additions & 8 deletions actions/setup/js/check_membership.cjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { parseRequiredPermissions, parseAllowedBots, checkRepositoryPermission, checkBotStatus, isAllowedBot, isConfusedDeputyAttack } = require("./check_permissions_utils.cjs");
const { parseRequiredPermissions, parseAllowedBots, canonicalizeBotIdentifier, checkRepositoryPermission, checkBotStatus, isAllowedBot, isConfusedDeputyAttack } = require("./check_permissions_utils.cjs");
const { writeDenialSummary } = require("./pre_activation_summary.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { withRetry, isTransientError } = require("./error_recovery.cjs");

function isCopilotPullRequestActor(eventName, actor) {
if (eventName !== "pull_request" && eventName !== "pull_request_target") {
return false;
}
const canonicalActor = canonicalizeBotIdentifier(actor);
const copilotBotNames = process.env.GH_AW_COPILOT_BOT_NAMES?.split(",").filter(Boolean) ?? [];
return copilotBotNames.some(name => canonicalizeBotIdentifier(name) === canonicalActor);
}

/**
* Attempt to authorize the actor via the bots allowlist.
*
Expand All @@ -32,13 +41,10 @@ async function checkBotAllowlistAuthorization(actorToValidate, allowedBots, owne

core.info(`Actor '${actorToValidate}' matched the allowed bots list: ${allowedBots.join(", ")}`);

// Built-in Copilot agents are not repository collaborators; the explicit allowlist
// is sufficient for their GitHub-provided actor identities.
const isBuiltInCopilot = actorToValidate === "Copilot" || actorToValidate === "copilot-swe-agent" || actorToValidate === "copilot-swe-agent[bot]";
const botStatus = isBuiltInCopilot ? { isBot: true, isActive: true } : await checkBotStatus(actorToValidate, owner, repo);
const botStatus = await checkBotStatus(actorToValidate, owner, repo);

if (botStatus.isBot && botStatus.isActive) {
core.info(isBuiltInCopilot ? `✅ Built-in bot '${actorToValidate}' is allowlisted and authorized` : `✅ Bot '${actorToValidate}' is active on the repository and authorized`);
core.info(`✅ Bot '${actorToValidate}' is active on the repository and authorized`);
core.setOutput("is_team_member", "true");
core.setOutput("result", "authorized_bot");
core.setOutput("user_permission", "bot");
Expand Down Expand Up @@ -240,7 +246,9 @@ async function main() {
const authorPermission = await checkRepositoryPermission(pullRequestAuthor, owner, repo, requiredPermissions);
if (authorPermission.authorized) {
core.info(`PR author '${pullRequestAuthor}' is trusted; checking whether bot '${actorToValidate}' is active`);
const botResult = await checkBotAllowlistAuthorization(actorToValidate, allowedBots, owner, repo);
const botResult = await checkBotAllowlistAuthorization(actorToValidate, allowedBots, owner, repo, {
installationCheckOptional: isCopilotPullRequestActor(eventName, actorToValidate),
});
if (botResult.handled) {
return;
}
Expand Down Expand Up @@ -270,7 +278,7 @@ async function main() {
// `contents: write` on this repository to post the event, so an explicitly allowlisted App is
// authorized even when that lookup finds nothing.
const botResult = await checkBotAllowlistAuthorization(actorToValidate, allowedBots, owner, repo, {
installationCheckOptional: eventName === "repository_dispatch",
installationCheckOptional: eventName === "repository_dispatch" || isCopilotPullRequestActor(eventName, actorToValidate),
});
if (botResult.handled) {
return;
Expand Down
55 changes: 40 additions & 15 deletions actions/setup/js/check_membership.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ describe("check_membership.cjs", () => {
delete global.context;
delete process.env.GH_AW_REQUIRED_ROLES;
delete process.env.GH_AW_ALLOWED_BOTS;
delete process.env.GH_AW_COPILOT_BOT_NAMES;
});

const runScript = async () => {
Expand Down Expand Up @@ -487,8 +488,9 @@ describe("check_membership.cjs", () => {
expect(mockCore.setOutput).not.toHaveBeenCalledWith("result", "confused_deputy");
});

it("should authorize allowlisted Copilot synchronizing a trusted author's same-repository PR without a bot lookup", async () => {
process.env.GH_AW_ALLOWED_BOTS = "Copilot";
it("should authorize allowlisted Copilot synchronizing a trusted author's same-repository PR after a conclusive missing-collaborator lookup", async () => {
process.env.GH_AW_ALLOWED_BOTS = "copilot-swe-agent,Copilot,copilot,@app/copilot-swe-agent";
process.env.GH_AW_COPILOT_BOT_NAMES = "copilot-swe-agent,Copilot,copilot,@app/copilot-swe-agent";
mockContext.actor = "Copilot";
mockContext.eventName = "pull_request";
mockContext.payload = {
Expand All @@ -499,12 +501,15 @@ describe("check_membership.cjs", () => {
base: { repo: { id: 123, full_name: "testorg/testrepo" } },
},
};
mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValueOnce({ data: { permission: "write" } });
mockGithub.rest.repos.getCollaboratorPermissionLevel
.mockResolvedValueOnce({ data: { permission: "write" } })
.mockRejectedValueOnce({ status: 404, message: "Not Found" })
.mockRejectedValueOnce({ status: 404, message: "Not Found" });

await runScript();

expect(mockCore.setOutput).toHaveBeenCalledWith("result", "authorized_bot");
expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).toHaveBeenCalledTimes(1);
expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).toHaveBeenCalledTimes(3);
expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).toHaveBeenCalledWith({ owner: "testorg", repo: "testrepo", username: "human-author" });
});

Expand Down Expand Up @@ -931,23 +936,43 @@ describe("check_membership.cjs", () => {
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "insufficient_permissions");
});

it.each(["Copilot", "copilot-swe-agent", "copilot-swe-agent[bot]"])("should authorize explicitly allowlisted built-in agent %s without a collaborator lookup on a PR", async actor => {
process.env.GH_AW_ALLOWED_BOTS = actor;
mockContext.actor = actor;
it.each(["Copilot", "Copilot[bot]", "copilot-swe-agent", "copilot-swe-agent[bot]", "copilot", "copilot[bot]", "@app/copilot-swe-agent", "@app/copilot-swe-agent[bot]"])(
"should authorize explicitly allowlisted Copilot alias %s after a conclusive missing-collaborator lookup on a PR",
async actor => {
process.env.GH_AW_ALLOWED_BOTS = "copilot-swe-agent,Copilot,copilot,@app/copilot-swe-agent";
process.env.GH_AW_COPILOT_BOT_NAMES = "copilot-swe-agent,Copilot,copilot,@app/copilot-swe-agent";
mockContext.actor = actor;
mockContext.eventName = "pull_request";
mockContext.payload = { action: "opened", pull_request: { user: { login: actor } } };
mockGithub.rest.repos.getCollaboratorPermissionLevel.mockRejectedValue({ status: 404, message: "Not Found" });

await runScript();

expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "true");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "authorized_bot");
expect(mockCore.setOutput).toHaveBeenCalledWith("user_permission", "bot");
expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).toHaveBeenCalledTimes(2);
}
);

it("should deny a Copilot alias when the collaborator lookup fails transiently", async () => {
process.env.GH_AW_ALLOWED_BOTS = "copilot-swe-agent,Copilot,copilot,@app/copilot-swe-agent";
process.env.GH_AW_COPILOT_BOT_NAMES = "copilot-swe-agent,Copilot,copilot,@app/copilot-swe-agent";
mockContext.actor = "copilot";
mockContext.eventName = "pull_request";
mockContext.payload = { action: "opened", pull_request: { user: { login: actor } } };
mockGithub.rest.repos.getCollaboratorPermissionLevel.mockRejectedValue({ status: 503, message: "Service Unavailable" });

await runScript();

expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "true");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "authorized_bot");
expect(mockCore.setOutput).toHaveBeenCalledWith("user_permission", "bot");
expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).not.toHaveBeenCalled();
expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "false");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "bot_not_active");
expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).toHaveBeenCalledTimes(1);
});

it("should still require a collaborator lookup for other Copilot-like actors", async () => {
process.env.GH_AW_ALLOWED_BOTS = "copilot[bot]";
mockContext.actor = "copilot[bot]";
it("should still require an installation check for other Copilot-like actors", async () => {
process.env.GH_AW_ALLOWED_BOTS = "copilot-imposter";
process.env.GH_AW_COPILOT_BOT_NAMES = "copilot-swe-agent,Copilot,copilot,@app/copilot-swe-agent";
mockContext.actor = "copilot-imposter[bot]";

const notFoundError = { status: 404, message: "Not Found" };
mockGithub.rest.repos.getCollaboratorPermissionLevel.mockRejectedValue(notFoundError); // all bot status checks → 404
Expand Down
2 changes: 1 addition & 1 deletion actions/setup/js/work_queue_compaction.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const { createHash } = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { test } = require("node:test");
import { test } from "vitest";
const { canonical } = require("./work_queue_codec.cjs");
const { main: planCompaction, planFor } = require("./work_queue_compaction_plan.cjs");
const { isRetryablePublicationError, main: apply, PLAN_MAX_BYTES, readPlan } = require("./work_queue_compaction_apply.cjs");
Expand Down
9 changes: 9 additions & 0 deletions pkg/workflow/bot_aliases.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,12 @@ func expandBotNames(bots []string) []string {
botAliasesLog.Printf("Expanded bot names: input=%d, output=%d", len(bots), len(result))
return result
}

func hasCopilotBotName(bots []string) bool {
for _, bot := range bots {
if setutil.Contains(copilotBotSet, bot) {
return true
}
}
return false
}
5 changes: 5 additions & 0 deletions pkg/workflow/bot_aliases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,8 @@ func TestExpandBotNames(t *testing.T) {
})
}
}

func TestHasCopilotBotName(t *testing.T) {
assert.True(t, hasCopilotBotName([]string{"dependabot[bot]", "copilot-swe-agent", "renovate[bot]"}))
assert.False(t, hasCopilotBotName([]string{"dependabot[bot]", "renovate[bot]"}))
}
5 changes: 5 additions & 0 deletions pkg/workflow/bots_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ Test workflow content.`
if !strings.Contains(compiledStr, `GH_AW_ALLOWED_BOTS: "dependabot[bot],renovate[bot]"`) {
t.Errorf("Expected compiled workflow to contain GH_AW_ALLOWED_BOTS environment variable")
}
if strings.Contains(compiledStr, "GH_AW_COPILOT_BOT_NAMES:") {
t.Errorf("Expected compiled workflow to omit GH_AW_COPILOT_BOT_NAMES for non-Copilot bots")
}

// Also check that roles are still present
if !strings.Contains(compiledStr, `GH_AW_REQUIRED_ROLES: "triage"`) {
Expand Down Expand Up @@ -323,6 +326,8 @@ Test workflow content.`
// The "copilot" alias must be expanded to all Copilot bot identities
assert.Contains(t, lockStr, `GH_AW_ALLOWED_BOTS: "copilot-swe-agent,Copilot,copilot,@app/copilot-swe-agent"`,
`Expected compiled workflow to expand "copilot" alias to all Copilot bot identifiers`)
assert.Contains(t, lockStr, `GH_AW_COPILOT_BOT_NAMES: "copilot-swe-agent,Copilot,copilot,@app/copilot-swe-agent"`,
`Expected compiled workflow to pass the canonical Copilot bot identifiers to membership validation`)
}

// TestBotsImportMerge tests that on.bots from imported workflows are merged with main workflow bots
Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/role_checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ func (c *Compiler) generateMembershipCheck(data *WorkflowData, steps []string) [
steps = append(steps, fmt.Sprintf(" GH_AW_REQUIRED_ROLES: %q\n", strings.Join(data.Roles, ",")))
if len(data.Bots) > 0 {
steps = append(steps, fmt.Sprintf(" GH_AW_ALLOWED_BOTS: %q\n", strings.Join(data.Bots, ",")))
if hasCopilotBotName(data.Bots) {
steps = append(steps, fmt.Sprintf(" GH_AW_COPILOT_BOT_NAMES: %q\n", strings.Join(constants.CopilotBotNames, ",")))
}
}
if data.AllowBotAuthoredTriggerComment {
steps = append(steps, " GH_AW_ALLOW_BOT_AUTHORED_TRIGGER_COMMENT: \"true\"\n")
Expand Down
Loading