From 1bbc9e7ff7d48e1299f7fa9698273d248e0cafea Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Thu, 15 Jan 2026 10:28:46 -0800 Subject: [PATCH] fix: add checkHumanActor to agent mode (#826) Fixes issue #641 where users were getting banned due to rapid successive Claude runs triggered by the synchronize event. Changes: - Add checkHumanActor call to agent mode's prepare() method to reject bot-triggered workflows unless explicitly allowed via allowed_bots - Update checkHumanActor to accept GitHubContext (union type) instead of just ParsedGitHubContext - Add tests for bot rejection/allowance in agent mode Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%) Claude-Steers: 1 Claude-Permission-Prompts: 3 Claude-Escapes: 0 --- src/github/validation/actor.ts | 4 +- src/modes/agent/index.ts | 10 ++++- test/modes/agent.test.ts | 67 ++++++++++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 7 deletions(-) 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" }, }), ), },