Skip to content

Commit 1dc994e

Browse files
authored
Resolve actor account type before applying allowed_bots (#1330)
Move the allowed_bots check in checkHumanActor and checkWritePermissions so it only fires after the actor has been resolved as a non-User account (GitHub App / bot, or unresolvable app actor). Actors that resolve to a regular User account go through the standard human/write checks regardless of allowed_bots. The Copilot-style path (GITHUB_ACTOR not ending in [bot] and not resolvable as a user) is unchanged: it still falls through to the existing 404 catch, which already consults allowed_bots once the API has reported the actor is not a user. Update tests to match and add coverage for the User-account path.
1 parent ca89df3 commit 1dc994e

4 files changed

Lines changed: 147 additions & 47 deletions

File tree

src/github/validation/actor.ts

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,35 +32,32 @@ export async function checkHumanActor(
3232
githubContext: GitHubContext,
3333
) {
3434
const allowedBots = githubContext.inputs.allowedBots;
35+
const actor = githubContext.actor;
3536

36-
// Check allowed_bots BEFORE calling the GitHub Users API.
37-
// Some bot actors (e.g. GitHub Copilot with GITHUB_ACTOR="Copilot") are
38-
// not resolvable via the Users API and would cause a 404 if we called it
39-
// first. By checking the allow-list early we avoid the unnecessary API
40-
// call and the resulting crash.
41-
if (isAllowedBot(githubContext.actor, allowedBots)) {
42-
console.log(
43-
`Actor ${githubContext.actor} is in allowed_bots list, skipping human actor check`,
44-
);
45-
return;
46-
}
47-
48-
// Fetch user information from GitHub API
37+
// Resolve the actor's account type before consulting allowed_bots so the
38+
// allow-list only ever applies to non-User accounts. Some app actors
39+
// (e.g. GitHub Copilot with GITHUB_ACTOR="Copilot") are not resolvable
40+
// via the Users API and 404 — that path is handled in the catch below.
4941
let actorType: string;
5042
try {
5143
const { data: userData } = await octokit.users.getByUsername({
52-
username: githubContext.actor,
44+
username: actor,
5345
});
5446
actorType = userData.type;
5547
} catch (error) {
56-
// Handle 404 for non-user actors (GitHub Apps whose GITHUB_ACTOR
57-
// doesn't match any user account, e.g. "Copilot").
5848
if (
5949
error instanceof Error &&
6050
(error.message.includes("Not Found") ||
6151
error.message.includes("is not a user"))
6252
) {
63-
const botName = githubContext.actor.toLowerCase().replace(/\[bot\]$/, "");
53+
// Unresolvable actors are GitHub Apps without a backing user account.
54+
if (isAllowedBot(actor, allowedBots)) {
55+
console.log(
56+
`Actor ${actor} is in allowed_bots list, skipping human actor check`,
57+
);
58+
return;
59+
}
60+
const botName = actor.toLowerCase().replace(/\[bot\]$/, "");
6461
throw new Error(
6562
`Workflow initiated by non-human actor: ${botName} (actor not found on GitHub). Add bot to allowed_bots list or use '*' to allow all bots.`,
6663
);
@@ -70,15 +67,22 @@ export async function checkHumanActor(
7067

7168
console.log(`Actor type: ${actorType}`);
7269

73-
// Check bot permissions if actor is not a User
7470
if (actorType !== "User") {
75-
const botName = githubContext.actor.toLowerCase().replace(/\[bot\]$/, "");
76-
77-
// Bot not allowed (we already checked allowed_bots above)
71+
// GitHub Apps and other bot accounts.
72+
if (isAllowedBot(actor, allowedBots)) {
73+
console.log(
74+
`Actor ${actor} is in allowed_bots list, skipping human actor check`,
75+
);
76+
return;
77+
}
78+
const botName = actor.toLowerCase().replace(/\[bot\]$/, "");
7879
throw new Error(
7980
`Workflow initiated by non-human actor: ${botName} (type: ${actorType}). Add bot to allowed_bots list or use '*' to allow all bots.`,
8081
);
8182
}
8283

83-
console.log(`Verified human actor: ${githubContext.actor}`);
84+
// Regular User account. allowed_bots is only for bot actors and is not
85+
// consulted here; write-access enforcement for users happens separately
86+
// in checkWritePermissions.
87+
console.log(`Verified human actor: ${actor}`);
8488
}

src/github/validation/permissions.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,22 +66,19 @@ export async function checkWritePermissions(
6666
}
6767
}
6868

69-
// Check if the actor is a GitHub App (bot user with [bot] suffix)
69+
// Check if the actor is a GitHub App (bot user with [bot] suffix).
70+
// Usernames cannot contain "[" or "]", so the suffix is a reliable
71+
// bot signal that doesn't require an API lookup.
7072
if (actor.endsWith("[bot]")) {
7173
core.info(`Actor is a GitHub App: ${actor}`);
7274
return true;
7375
}
7476

75-
// Check if the actor is in the allowed bots list (handles non-[bot] actors
76-
// like GitHub Copilot whose GITHUB_ACTOR is "Copilot", not "Copilot[bot]")
77-
if (isAllowedBot(actor, allowedBots)) {
78-
core.info(
79-
`Actor ${actor} is in allowed_bots list, skipping permission check`,
80-
);
81-
return true;
82-
}
83-
84-
// Check permissions directly using the permission endpoint
77+
// For all other actors, resolve the account via the collaborator
78+
// permission endpoint. allowed_bots is only consulted in the catch
79+
// block below, after the API has confirmed the actor is not a regular
80+
// user account (e.g. GitHub Apps like Copilot whose GITHUB_ACTOR is
81+
// "Copilot" rather than "Copilot[bot]").
8582
const response = await octokit.repos.getCollaboratorPermissionLevel({
8683
owner: repository.owner,
8784
repo: repository.repo,

test/actor.test.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ describe("checkHumanActor", () => {
9797
describe("non-[bot] actors (e.g. GitHub Copilot)", () => {
9898
// GitHub Copilot SWE Agent sets GITHUB_ACTOR="Copilot" which is not a
9999
// valid GitHub user and doesn't end with [bot], causing 404 on the
100-
// Users API. These tests verify the fix handles this gracefully.
100+
// Users API. allowed_bots is applied once the API has resolved the
101+
// actor as not being a regular user account.
101102

102103
function createMockOctokitThat404s(): Octokit {
103104
return {
@@ -117,7 +118,6 @@ describe("checkHumanActor", () => {
117118
context.actor = "Copilot";
118119
context.inputs.allowedBots = "copilot,cursor";
119120

120-
// Should not even call the API — allowed_bots check happens first
121121
await expect(
122122
checkHumanActor(mockOctokit, context),
123123
).resolves.toBeUndefined();
@@ -167,4 +167,52 @@ describe("checkHumanActor", () => {
167167
).resolves.toBeUndefined();
168168
});
169169
});
170+
171+
describe("account type resolution", () => {
172+
// The Users API resolves the actor's account type before allowed_bots
173+
// is consulted. allowed_bots is only relevant for Bot accounts and
174+
// unresolvable app actors; it does not change behavior for regular
175+
// User accounts.
176+
177+
test("should pass for a User account whose name matches allowed_bots", async () => {
178+
const mockOctokit = createMockOctokit("User");
179+
const context = createMockContext();
180+
context.actor = "renovate";
181+
context.inputs.allowedBots = "renovate";
182+
183+
await expect(
184+
checkHumanActor(mockOctokit, context),
185+
).resolves.toBeUndefined();
186+
});
187+
188+
test("should pass for a User account when allowed_bots is '*'", async () => {
189+
const mockOctokit = createMockOctokit("User");
190+
const context = createMockContext();
191+
context.actor = "some-user";
192+
context.inputs.allowedBots = "*";
193+
194+
await expect(
195+
checkHumanActor(mockOctokit, context),
196+
).resolves.toBeUndefined();
197+
});
198+
199+
test("should resolve account type even when actor name appears in allowed_bots", async () => {
200+
// The Users API call should not be short-circuited by allowed_bots,
201+
// so an unexpected API error propagates instead of being swallowed.
202+
const mockOctokit = {
203+
users: {
204+
getByUsername: async () => {
205+
throw new Error("Internal Server Error");
206+
},
207+
},
208+
} as unknown as Octokit;
209+
const context = createMockContext();
210+
context.actor = "some-user";
211+
context.inputs.allowedBots = "some-user";
212+
213+
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
214+
"Internal Server Error",
215+
);
216+
});
217+
});
170218
});

test/permissions.test.ts

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,9 @@ describe("checkWritePermissions", () => {
307307
describe("non-[bot] actors (e.g. GitHub Copilot)", () => {
308308
// GitHub Copilot SWE Agent sets GITHUB_ACTOR="Copilot" which doesn't
309309
// end with [bot] and is not a valid GitHub user, so the collaborator
310-
// permission API returns 404 with "is not a user".
310+
// permission API returns 404 with "is not a user". allowed_bots is
311+
// applied in that catch path once the API has confirmed the actor is
312+
// not a regular user account.
311313

312314
const createMockOctokitThat404s = () =>
313315
({
@@ -322,9 +324,7 @@ describe("checkWritePermissions", () => {
322324
},
323325
}) as any;
324326

325-
test("should return true for non-[bot] actor in allowed_bots (pre-API check)", async () => {
326-
// The allowed_bots check should happen BEFORE calling the API,
327-
// so this should succeed even with a 404-ing mock.
327+
test("should return true for non-[bot] app actor in allowed_bots", async () => {
328328
const mockOctokit = createMockOctokitThat404s();
329329
const context = createContext();
330330
context.actor = "Copilot";
@@ -334,11 +334,11 @@ describe("checkWritePermissions", () => {
334334

335335
expect(result).toBe(true);
336336
expect(coreInfoSpy).toHaveBeenCalledWith(
337-
"Actor Copilot is in allowed_bots list, skipping permission check",
337+
"Non-user actor Copilot is in allowed_bots list, granting access",
338338
);
339339
});
340340

341-
test("should return true for non-[bot] actor when allowed_bots is '*' (pre-API check)", async () => {
341+
test("should return true for non-[bot] app actor when allowed_bots is '*'", async () => {
342342
const mockOctokit = createMockOctokitThat404s();
343343
const context = createContext();
344344
context.actor = "Copilot";
@@ -349,20 +349,18 @@ describe("checkWritePermissions", () => {
349349
expect(result).toBe(true);
350350
});
351351

352-
test("should return true for non-[bot] actor in allowed_bots via 404 fallback", async () => {
353-
// Even if somehow we reach the API call (e.g. race condition or
354-
// future refactor), the 404 catch path should also check allowed_bots.
352+
test("should match config entries written with the [bot] suffix", async () => {
355353
const mockOctokit = createMockOctokitThat404s();
356354
const context = createContext();
357355
context.actor = "SomeNewBot";
358-
context.inputs.allowedBots = "somenewbot";
356+
context.inputs.allowedBots = "somenewbot[bot]";
359357

360358
const result = await checkWritePermissions(mockOctokit, context);
361359

362360
expect(result).toBe(true);
363361
});
364362

365-
test("should return false for non-[bot] actor that 404s and is not in allowed_bots", async () => {
363+
test("should return false for non-[bot] app actor that is not in allowed_bots", async () => {
366364
const mockOctokit = createMockOctokitThat404s();
367365
const context = createContext();
368366
context.actor = "Copilot";
@@ -376,7 +374,7 @@ describe("checkWritePermissions", () => {
376374
);
377375
});
378376

379-
test("should return false for non-[bot] actor that 404s with empty allowed_bots", async () => {
377+
test("should return false for non-[bot] app actor with empty allowed_bots", async () => {
380378
const mockOctokit = createMockOctokitThat404s();
381379
const context = createContext();
382380
context.actor = "Copilot";
@@ -404,4 +402,57 @@ describe("checkWritePermissions", () => {
404402
);
405403
});
406404
});
405+
406+
describe("allowed_bots only applies to non-user actors", () => {
407+
// The permission endpoint resolves the actor's account type. Actors
408+
// that resolve to a regular user account go through the standard write
409+
// permission check; allowed_bots does not short-circuit it for them.
410+
411+
test("should require write permission for a user account whose name matches allowed_bots", async () => {
412+
const mockOctokit = createMockOctokit("read");
413+
const context = createContext();
414+
context.actor = "renovate";
415+
context.inputs.allowedBots = "renovate";
416+
417+
const result = await checkWritePermissions(mockOctokit, context);
418+
419+
expect(result).toBe(false);
420+
expect(coreWarningSpy).toHaveBeenCalledWith(
421+
"Actor has insufficient permissions: read",
422+
);
423+
});
424+
425+
test("should require write permission for a user account when allowed_bots uses the [bot] form", async () => {
426+
const mockOctokit = createMockOctokit("read");
427+
const context = createContext();
428+
context.actor = "renovate";
429+
context.inputs.allowedBots = "renovate[bot]";
430+
431+
const result = await checkWritePermissions(mockOctokit, context);
432+
433+
expect(result).toBe(false);
434+
});
435+
436+
test("should require write permission for a user account when allowed_bots is '*'", async () => {
437+
const mockOctokit = createMockOctokit("none");
438+
const context = createContext();
439+
context.actor = "some-user";
440+
context.inputs.allowedBots = "*";
441+
442+
const result = await checkWritePermissions(mockOctokit, context);
443+
444+
expect(result).toBe(false);
445+
});
446+
447+
test("should still grant access for a user account with write permission", async () => {
448+
const mockOctokit = createMockOctokit("write");
449+
const context = createContext();
450+
context.actor = "renovate";
451+
context.inputs.allowedBots = "renovate";
452+
453+
const result = await checkWritePermissions(mockOctokit, context);
454+
455+
expect(result).toBe(true);
456+
});
457+
});
407458
});

0 commit comments

Comments
 (0)