diff --git a/src/github/validation/actor.ts b/src/github/validation/actor.ts index 2599254..36be2ff 100644 --- a/src/github/validation/actor.ts +++ b/src/github/validation/actor.ts @@ -6,11 +6,11 @@ */ import type { Octokit } from "@octokit/rest"; -import type { ParsedGitHubContext } from "../context"; +import type { GitHubContext } from "../context"; export async function checkHumanActor( octokit: Octokit, - githubContext: ParsedGitHubContext, + githubContext: GitHubContext, ) { // Fetch user information from GitHub API const { data: userData } = await octokit.users.getByUsername({ diff --git a/src/modes/agent/index.ts b/src/modes/agent/index.ts index 1b992a7..59b78b4 100644 --- a/src/modes/agent/index.ts +++ b/src/modes/agent/index.ts @@ -8,6 +8,7 @@ import { configureGitAuth, setupSshSigning, } from "../../github/operations/git-config"; +import { checkHumanActor } from "../../github/validation/actor"; import type { GitHubContext } from "../../github/context"; import { isEntityContext } from "../../github/context"; @@ -80,7 +81,14 @@ export const agentMode: Mode = { return false; }, - async prepare({ context, githubToken }: ModeOptions): Promise { + async prepare({ + context, + octokit, + githubToken, + }: ModeOptions): Promise { + // Check if actor is human (prevents bot-triggered loops) + await checkHumanActor(octokit.rest, context); + // Configure git authentication for agent mode (same as tag mode) // SSH signing takes precedence if provided const useSshSigning = !!context.inputs.sshSigningKey; diff --git a/test/modes/agent.test.ts b/test/modes/agent.test.ts index 16e3796..25bf844 100644 --- a/test/modes/agent.test.ts +++ b/test/modes/agent.test.ts @@ -145,12 +145,12 @@ describe("Agent Mode", () => { users: { getAuthenticated: mock(() => Promise.resolve({ - data: { login: "test-user", id: 12345 }, + data: { login: "test-user", id: 12345, type: "User" }, }), ), getByUsername: mock(() => Promise.resolve({ - data: { login: "test-user", id: 12345 }, + data: { login: "test-user", id: 12345, type: "User" }, }), ), }, @@ -187,6 +187,65 @@ describe("Agent Mode", () => { process.env.GITHUB_REF_NAME = originalRefName; }); + test("prepare method rejects bot actors without allowed_bots", async () => { + const contextWithPrompts = createMockAutomationContext({ + eventName: "workflow_dispatch", + }); + contextWithPrompts.actor = "claude[bot]"; + contextWithPrompts.inputs.allowedBots = ""; + + const mockOctokit = { + rest: { + users: { + getByUsername: mock(() => + Promise.resolve({ + data: { login: "claude[bot]", id: 12345, type: "Bot" }, + }), + ), + }, + }, + } as any; + + await expect( + agentMode.prepare({ + context: contextWithPrompts, + octokit: mockOctokit, + githubToken: "test-token", + }), + ).rejects.toThrow( + "Workflow initiated by non-human actor: claude (type: Bot)", + ); + }); + + test("prepare method allows bot actors when in allowed_bots list", async () => { + const contextWithPrompts = createMockAutomationContext({ + eventName: "workflow_dispatch", + }); + contextWithPrompts.actor = "dependabot[bot]"; + contextWithPrompts.inputs.allowedBots = "dependabot"; + + const mockOctokit = { + rest: { + users: { + getByUsername: mock(() => + Promise.resolve({ + data: { login: "dependabot[bot]", id: 12345, type: "Bot" }, + }), + ), + }, + }, + } as any; + + // Should not throw - bot is in allowed list + await expect( + agentMode.prepare({ + context: contextWithPrompts, + octokit: mockOctokit, + githubToken: "test-token", + }), + ).resolves.toBeDefined(); + }); + test("prepare method creates prompt file with correct content", async () => { const contextWithPrompts = createMockAutomationContext({ eventName: "workflow_dispatch", @@ -199,12 +258,12 @@ describe("Agent Mode", () => { users: { getAuthenticated: mock(() => Promise.resolve({ - data: { login: "test-user", id: 12345 }, + data: { login: "test-user", id: 12345, type: "User" }, }), ), getByUsername: mock(() => Promise.resolve({ - data: { login: "test-user", id: 12345 }, + data: { login: "test-user", id: 12345, type: "User" }, }), ), },