Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ jobs:
# Optional: grant additional permissions (requires corresponding GitHub token permissions)
# additional_permissions: |
# actions: read
# Optional: allow specific bots to trigger Claude
# allowed_bots: "dependabot,renovate"
```

## Inputs
Expand Down Expand Up @@ -193,6 +195,7 @@ jobs:
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots | No | "" |

\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)

Expand Down Expand Up @@ -799,7 +802,7 @@ Both AWS Bedrock and GCP Vertex AI require OIDC authentication.
### Access Control

- **Repository Access**: The action can only be triggered by users with write access to the repository
- **No Bot Triggers**: GitHub Apps and bots cannot trigger this action
- **Bot User Control**: By default, GitHub Apps and bots cannot trigger this action for security reasons. Use the `allowed_bots` parameter to enable specific bots or all bots
- **Token Permissions**: The GitHub app receives only a short-lived token scoped specifically to the repository it's operating in
- **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Thank you for trying out the beta of our GitHub Action! This document outlines o
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services
- **Ability to disable commit signing** - Option to turn off GPG signing for environments where it's not required. This will enable Claude to use normal `git` bash commands for committing. This will likely become the default behavior once added.
- **Better code review behavior** - Support inline comments on specific lines, provide higher quality reviews with more actionable feedback
- **Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude
- ~**Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude~
- **Customizable base prompts** - Full control over Claude's initial context with template variables like `$PR_COMMENTS`, `$PR_FILES`, etc. Users can replace our default prompt entirely while still accessing key contextual data

---
Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down
2 changes: 2 additions & 0 deletions src/github/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type ParsedGitHubContext = {
useStickyComment: boolean;
additionalPermissions: Map<string, string>;
useCommitSigning: boolean;
allowedBots: string;
};
};

Expand Down Expand Up @@ -70,6 +71,7 @@ export function parseGitHubContext(): ParsedGitHubContext {
process.env.ADDITIONAL_PERMISSIONS ?? "",
),
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
allowedBots: process.env.ALLOWED_BOTS ?? "",
},
};

Expand Down
28 changes: 27 additions & 1 deletion src/github/validation/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,35 @@ export async function checkHumanActor(

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

// Check bot permissions if actor is not a User
if (actorType !== "User") {
const allowedBots = githubContext.inputs.allowedBots;

// 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.`,
);
}

Expand Down
6 changes: 6 additions & 0 deletions src/github/validation/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export async function checkWritePermissions(
try {
core.info(`Checking permissions for actor: ${actor}`);

// Check if the actor is a GitHub App (bot user)
if (actor.endsWith("[bot]")) {
core.info(`Actor is a GitHub App: ${actor}`);
return true;
}

// Check permissions directly using the permission endpoint
const response = await octokit.repos.getCollaboratorPermissionLevel({
owner: repository.owner,
Expand Down
74 changes: 74 additions & 0 deletions test/actor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/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";
context.inputs.allowedBots = "";

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";
context.inputs.allowedBots = "*";

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";
context.inputs.allowedBots = "dependabot,renovate";

await expect(
checkHumanActor(mockOctokit, context),
).resolves.toBeUndefined();
});

test("should throw error for bot not in allowed list", async () => {
const mockOctokit = createMockOctokit("Bot");
const context = createMockContext();
context.actor = "other-bot";
context.inputs.allowedBots = "dependabot,renovate";

await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
);
});
});
1 change: 1 addition & 0 deletions test/install-mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe("prepareMcpConfig", () => {
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
allowedBots: "",
},
};

Expand Down
1 change: 1 addition & 0 deletions test/mockContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const defaultInputs = {
useStickyComment: false,
additionalPermissions: new Map<string, string>(),
useCommitSigning: false,
allowedBots: "",
};

const defaultRepository = {
Expand Down
10 changes: 10 additions & 0 deletions test/permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ describe("checkWritePermissions", () => {
);
});

test("should return true for bot user", async () => {
const mockOctokit = createMockOctokit("none");
const context = createContext();
context.actor = "test-bot[bot]";

const result = await checkWritePermissions(mockOctokit, context);

expect(result).toBe(true);
});

test("should throw error when permission check fails", async () => {
const error = new Error("API error");
const mockOctokit = {
Expand Down
5 changes: 5 additions & 0 deletions test/trigger-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ describe("checkContainsTrigger", () => {
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
allowedBots: "",
},
});
expect(checkContainsTrigger(context)).toBe(true);
Expand Down Expand Up @@ -70,6 +71,7 @@ describe("checkContainsTrigger", () => {
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
allowedBots: "",
},
});
expect(checkContainsTrigger(context)).toBe(false);
Expand Down Expand Up @@ -285,6 +287,7 @@ describe("checkContainsTrigger", () => {
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
allowedBots: "",
},
});
expect(checkContainsTrigger(context)).toBe(true);
Expand Down Expand Up @@ -317,6 +320,7 @@ describe("checkContainsTrigger", () => {
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
allowedBots: "",
},
});
expect(checkContainsTrigger(context)).toBe(true);
Expand Down Expand Up @@ -349,6 +353,7 @@ describe("checkContainsTrigger", () => {
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
allowedBots: "",
},
});
expect(checkContainsTrigger(context)).toBe(false);
Expand Down