diff --git a/action.yml b/action.yml index 708219a..199210d 100644 --- a/action.yml +++ b/action.yml @@ -23,6 +23,10 @@ inputs: description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)" required: false default: "claude/" + allowed_bots: + description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots." + required: false + default: "" # Claude Code configuration model: @@ -144,6 +148,7 @@ runs: DIRECT_PROMPT: ${{ inputs.direct_prompt }} MCP_CONFIG: ${{ inputs.mcp_config }} OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} + ALLOWED_BOTS: ${{ inputs.allowed_bots }} GITHUB_RUN_ID: ${{ github.run_id }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} ACTIONS_TOKEN: ${{ github.token }} diff --git a/src/entrypoints/prepare.ts b/src/entrypoints/prepare.ts index d5e968f..0b1c551 100644 --- a/src/entrypoints/prepare.ts +++ b/src/entrypoints/prepare.ts @@ -48,7 +48,8 @@ async function run() { } // Step 5: Check if actor is human - await checkHumanActor(octokit.rest, context); + const allowedBots = process.env.ALLOWED_BOTS || ""; + await checkHumanActor(octokit.rest, context, allowedBots); // Step 6: Create initial tracking comment const commentData = await createInitialComment(octokit.rest, context); diff --git a/src/github/validation/actor.ts b/src/github/validation/actor.ts index c48764b..4d430af 100644 --- a/src/github/validation/actor.ts +++ b/src/github/validation/actor.ts @@ -11,6 +11,7 @@ import type { ParsedGitHubContext } from "../context"; export async function checkHumanActor( octokit: Octokit, githubContext: ParsedGitHubContext, + allowedBots: string, ) { // Fetch user information from GitHub API const { data: userData } = await octokit.users.getByUsername({ @@ -21,9 +22,33 @@ export async function checkHumanActor( console.log(`Actor type: ${actorType}`); + // Check bot permissions if actor is not a User if (actorType !== "User") { + // Parse allowed bots list + const allowedBotsList = allowedBots + .split(",") + .map((bot) => bot.trim().toLowerCase()) + .filter((bot) => bot.length > 0); + + // Check if all bots are allowed + if (allowedBots.trim() === "*") { + console.log( + `All bots are allowed, skipping human actor check for: ${githubContext.actor}`, + ); + return; + } + + // Check if specific bot is allowed + if (allowedBotsList.includes(githubContext.actor.toLowerCase())) { + console.log( + `Bot ${githubContext.actor} is in allowed list, skipping human actor check`, + ); + return; + } + + // Bot not allowed throw new Error( - `Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`, + `Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}). Add bot to allowed_bots list or use '*' to allow all bots.`, ); } diff --git a/test/actor.test.ts b/test/actor.test.ts new file mode 100644 index 0000000..bdb13f7 --- /dev/null +++ b/test/actor.test.ts @@ -0,0 +1,72 @@ +#!/usr/bin/env bun + +import { describe, test, expect } from "bun:test"; +import { checkHumanActor } from "../src/github/validation/actor"; +import type { Octokit } from "@octokit/rest"; +import { createMockContext } from "./mockContext"; + +function createMockOctokit(userType: string): Octokit { + return { + users: { + getByUsername: async () => ({ + data: { + type: userType, + }, + }), + }, + } as unknown as Octokit; +} + +describe("checkHumanActor", () => { + test("should pass for human actor", async () => { + const mockOctokit = createMockOctokit("User"); + const context = createMockContext(); + context.actor = "human-user"; + + await expect( + checkHumanActor(mockOctokit, context, ""), + ).resolves.toBeUndefined(); + }); + + test("should throw error for bot actor when not allowed", async () => { + const mockOctokit = createMockOctokit("Bot"); + const context = createMockContext(); + context.actor = "test-bot"; + + await expect(checkHumanActor(mockOctokit, context, "")).rejects.toThrow( + "Workflow initiated by non-human actor: test-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.", + ); + }); + + test("should pass for bot actor when all bots allowed", async () => { + const mockOctokit = createMockOctokit("Bot"); + const context = createMockContext(); + context.actor = "test-bot"; + + await expect( + checkHumanActor(mockOctokit, context, "*"), + ).resolves.toBeUndefined(); + }); + + test("should pass for specific bot when in allowed list", async () => { + const mockOctokit = createMockOctokit("Bot"); + const context = createMockContext(); + context.actor = "dependabot"; + + await expect( + checkHumanActor(mockOctokit, context, "dependabot,renovate"), + ).resolves.toBeUndefined(); + }); + + test("should throw error for bot not in allowed list", async () => { + const mockOctokit = createMockOctokit("Bot"); + const context = createMockContext(); + context.actor = "other-bot"; + + await expect( + checkHumanActor(mockOctokit, context, "dependabot,renovate"), + ).rejects.toThrow( + "Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.", + ); + }); +});