fix: add checkHumanActor to agent mode and concurrency to workflow examples

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 concurrency protection to all PR review workflow examples to
  prevent multiple reviews running simultaneously on the same PR
- Add tests for bot rejection/allowance in agent mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ashwin Bhat 2025-12-15 17:03:07 -08:00
parent 9acae263e7
commit df10c4692a
No known key found for this signature in database
8 changed files with 129 additions and 7 deletions

View File

@ -54,6 +54,11 @@ on:
pull_request:
types: [opened, synchronize]
# Prevent multiple reviews from running simultaneously on the same PR
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review-by-author:
if: |

View File

@ -27,6 +27,11 @@ on:
pull_request:
types: [opened, synchronize]
# Prevent multiple reviews from running simultaneously on the same PR
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review:
runs-on: ubuntu-latest
@ -81,6 +86,11 @@ on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Prevent multiple reviews from running simultaneously on the same PR
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review:
runs-on: ubuntu-latest
@ -145,6 +155,11 @@ on:
- "src/api/**"
- "config/security.yml"
# Prevent multiple reviews from running simultaneously on the same PR
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
security-review:
runs-on: ubuntu-latest
@ -202,6 +217,11 @@ on:
pull_request:
types: [opened, synchronize]
# Prevent multiple reviews from running simultaneously on the same PR
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
external-review:
if: github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
@ -260,6 +280,11 @@ on:
pull_request:
types: [opened, synchronize]
# Prevent multiple reviews from running simultaneously on the same PR
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
checklist-review:
runs-on: ubuntu-latest
@ -448,6 +473,11 @@ on:
- "src/api/**/*.ts"
- "src/routes/**/*.ts"
# Prevent multiple reviews from running simultaneously on the same PR
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
doc-sync:
runs-on: ubuntu-latest
@ -504,6 +534,11 @@ on:
pull_request:
types: [opened, synchronize]
# Prevent multiple reviews from running simultaneously on the same PR
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
security:
runs-on: ubuntu-latest

View File

@ -7,6 +7,11 @@ on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Prevent multiple reviews from running simultaneously on the same PR
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review-with-tracking:
runs-on: ubuntu-latest

View File

@ -4,6 +4,11 @@ on:
pull_request:
types: [opened, synchronize]
# Prevent multiple reviews from running simultaneously on the same PR
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review-by-author:
# Only run for PRs from specific authors

View File

@ -10,6 +10,11 @@ on:
- "api/**/*.py"
# You can add more specific patterns as needed
# Prevent multiple reviews from running simultaneously on the same PR
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
claude-review-paths:
runs-on: ubuntu-latest

View File

@ -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({

View File

@ -5,6 +5,7 @@ import type { PreparedContext } from "../../create-prompt/types";
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
import { parseAllowedTools } from "./parse-tools";
import { configureGitAuth } from "../../github/operations/git-config";
import { checkHumanActor } from "../../github/validation/actor";
import type { GitHubContext } from "../../github/context";
import { isEntityContext } from "../../github/context";
@ -77,7 +78,14 @@ export const agentMode: Mode = {
return false;
},
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
async prepare({
context,
octokit,
githubToken,
}: ModeOptions): Promise<ModeResult> {
// Check if actor is human (prevents bot-triggered loops)
await checkHumanActor(octokit.rest, context);
// Configure git authentication for agent mode (same as tag mode)
if (!context.inputs.useCommitSigning) {
// Use bot_id and bot_name from inputs directly

View File

@ -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" },
}),
),
},