Files
claude-code-action/src/github/context.ts
T
Yuku KotaniandClaude fec554fc7c feat: add flexible bot access control with allowed_bots option (#117)
* feat: skip permission check for GitHub App bot users

GitHub Apps (users ending with [bot]) now bypass permission checks
as they have their own authorization mechanism.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add allow_bot_users option to control bot user access

- Add allow_bot_users input parameter (default: false)
- Modify checkHumanActor to optionally allow bot users
- Add comprehensive tests for bot user handling
- Improve security by blocking bot users by default

This change prevents potential prompt injection attacks from bot users
while providing flexibility for trusted bot integrations.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: mark bot user support feature as completed in roadmap

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

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: move allowedBots parameter to context object

Move allowedBots from function parameter to context.inputs to maintain
consistency with other input handling throughout the codebase.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: update README for bot user support feature

Add documentation for the new allowed_bots parameter that enables
bot users to trigger Claude actions with granular control.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: add missing allowedBots property in permissions test

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

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: update bot name format to include [bot] suffix in tests and docs

- Update test cases to use correct bot actor names with [bot] suffix
- Update documentation example to show correct bot name format
- Align with GitHub's actual bot naming convention

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

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: normalize bot names for allowed_bots validation

- Strip [bot] suffix from both actor names and allowed bot list for comparison
- Allow both "dependabot" and "dependabot[bot]" formats in allowed_bots input
- Display normalized bot names in error messages for consistency
- Add comprehensive test coverage for both naming formats

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-07 18:03:20 -07:00

292 lines
8.1 KiB
TypeScript

import * as github from "@actions/github";
import type {
IssuesEvent,
IssuesAssignedEvent,
IssueCommentEvent,
PullRequestEvent,
PullRequestReviewEvent,
PullRequestReviewCommentEvent,
} from "@octokit/webhooks-types";
// Custom types for GitHub Actions events that aren't webhooks
export type WorkflowDispatchEvent = {
action?: never;
inputs?: Record<string, any>;
ref?: string;
repository: {
name: string;
owner: {
login: string;
};
};
sender: {
login: string;
};
workflow: string;
};
export type ScheduleEvent = {
action?: never;
schedule?: string;
repository: {
name: string;
owner: {
login: string;
};
};
};
import type { ModeName } from "../modes/types";
import { DEFAULT_MODE, isValidMode } from "../modes/registry";
// Event name constants for better maintainability
const ENTITY_EVENT_NAMES = [
"issues",
"issue_comment",
"pull_request",
"pull_request_review",
"pull_request_review_comment",
] as const;
const AUTOMATION_EVENT_NAMES = ["workflow_dispatch", "schedule"] as const;
// Derive types from constants for better maintainability
type EntityEventName = (typeof ENTITY_EVENT_NAMES)[number];
type AutomationEventName = (typeof AUTOMATION_EVENT_NAMES)[number];
// Common fields shared by all context types
type BaseContext = {
runId: string;
eventAction?: string;
repository: {
owner: string;
repo: string;
full_name: string;
};
actor: string;
inputs: {
mode: ModeName;
triggerPhrase: string;
assigneeTrigger: string;
labelTrigger: string;
allowedTools: string[];
disallowedTools: string[];
customInstructions: string;
directPrompt: string;
overridePrompt: string;
baseBranch?: string;
branchPrefix: string;
useStickyComment: boolean;
additionalPermissions: Map<string, string>;
useCommitSigning: boolean;
allowedBots: string;
};
};
// Context for entity-based events (issues, PRs, comments)
export type ParsedGitHubContext = BaseContext & {
eventName: EntityEventName;
payload:
| IssuesEvent
| IssueCommentEvent
| PullRequestEvent
| PullRequestReviewEvent
| PullRequestReviewCommentEvent;
entityNumber: number;
isPR: boolean;
};
// Context for automation events (workflow_dispatch, schedule)
export type AutomationContext = BaseContext & {
eventName: AutomationEventName;
payload: WorkflowDispatchEvent | ScheduleEvent;
};
// Union type for all contexts
export type GitHubContext = ParsedGitHubContext | AutomationContext;
export function parseGitHubContext(): GitHubContext {
const context = github.context;
const modeInput = process.env.MODE ?? DEFAULT_MODE;
if (!isValidMode(modeInput)) {
throw new Error(`Invalid mode: ${modeInput}.`);
}
const commonFields = {
runId: process.env.GITHUB_RUN_ID!,
eventAction: context.payload.action,
repository: {
owner: context.repo.owner,
repo: context.repo.repo,
full_name: `${context.repo.owner}/${context.repo.repo}`,
},
actor: context.actor,
inputs: {
mode: modeInput as ModeName,
triggerPhrase: process.env.TRIGGER_PHRASE ?? "@claude",
assigneeTrigger: process.env.ASSIGNEE_TRIGGER ?? "",
labelTrigger: process.env.LABEL_TRIGGER ?? "",
allowedTools: parseMultilineInput(process.env.ALLOWED_TOOLS ?? ""),
disallowedTools: parseMultilineInput(process.env.DISALLOWED_TOOLS ?? ""),
customInstructions: process.env.CUSTOM_INSTRUCTIONS ?? "",
directPrompt: process.env.DIRECT_PROMPT ?? "",
overridePrompt: process.env.OVERRIDE_PROMPT ?? "",
baseBranch: process.env.BASE_BRANCH,
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
additionalPermissions: parseAdditionalPermissions(
process.env.ADDITIONAL_PERMISSIONS ?? "",
),
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
allowedBots: process.env.ALLOWED_BOTS ?? "",
},
};
switch (context.eventName) {
case "issues": {
const payload = context.payload as IssuesEvent;
return {
...commonFields,
eventName: "issues",
payload,
entityNumber: payload.issue.number,
isPR: false,
};
}
case "issue_comment": {
const payload = context.payload as IssueCommentEvent;
return {
...commonFields,
eventName: "issue_comment",
payload,
entityNumber: payload.issue.number,
isPR: Boolean(payload.issue.pull_request),
};
}
case "pull_request": {
const payload = context.payload as PullRequestEvent;
return {
...commonFields,
eventName: "pull_request",
payload,
entityNumber: payload.pull_request.number,
isPR: true,
};
}
case "pull_request_review": {
const payload = context.payload as PullRequestReviewEvent;
return {
...commonFields,
eventName: "pull_request_review",
payload,
entityNumber: payload.pull_request.number,
isPR: true,
};
}
case "pull_request_review_comment": {
const payload = context.payload as PullRequestReviewCommentEvent;
return {
...commonFields,
eventName: "pull_request_review_comment",
payload,
entityNumber: payload.pull_request.number,
isPR: true,
};
}
case "workflow_dispatch": {
return {
...commonFields,
eventName: "workflow_dispatch",
payload: context.payload as unknown as WorkflowDispatchEvent,
};
}
case "schedule": {
return {
...commonFields,
eventName: "schedule",
payload: context.payload as unknown as ScheduleEvent,
};
}
default:
throw new Error(`Unsupported event type: ${context.eventName}`);
}
}
export function parseMultilineInput(s: string): string[] {
return s
.split(/,|[\n\r]+/)
.map((tool) => tool.replace(/#.+$/, ""))
.map((tool) => tool.trim())
.filter((tool) => tool.length > 0);
}
export function parseAdditionalPermissions(s: string): Map<string, string> {
const permissions = new Map<string, string>();
if (!s || !s.trim()) {
return permissions;
}
const lines = s.trim().split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine) {
const [key, value] = trimmedLine.split(":").map((part) => part.trim());
if (key && value) {
permissions.set(key, value);
}
}
}
return permissions;
}
export function isIssuesEvent(
context: GitHubContext,
): context is ParsedGitHubContext & { payload: IssuesEvent } {
return context.eventName === "issues";
}
export function isIssueCommentEvent(
context: GitHubContext,
): context is ParsedGitHubContext & { payload: IssueCommentEvent } {
return context.eventName === "issue_comment";
}
export function isPullRequestEvent(
context: GitHubContext,
): context is ParsedGitHubContext & { payload: PullRequestEvent } {
return context.eventName === "pull_request";
}
export function isPullRequestReviewEvent(
context: GitHubContext,
): context is ParsedGitHubContext & { payload: PullRequestReviewEvent } {
return context.eventName === "pull_request_review";
}
export function isPullRequestReviewCommentEvent(
context: GitHubContext,
): context is ParsedGitHubContext & { payload: PullRequestReviewCommentEvent } {
return context.eventName === "pull_request_review_comment";
}
export function isIssuesAssignedEvent(
context: GitHubContext,
): context is ParsedGitHubContext & { payload: IssuesAssignedEvent } {
return isIssuesEvent(context) && context.eventAction === "assigned";
}
// Type guard to check if context is an entity context (has entityNumber and isPR)
export function isEntityContext(
context: GitHubContext,
): context is ParsedGitHubContext {
return ENTITY_EVENT_NAMES.includes(context.eventName as EntityEventName);
}
// Type guard to check if context is an automation context
export function isAutomationContext(
context: GitHubContext,
): context is AutomationContext {
return AUTOMATION_EVENT_NAMES.includes(
context.eventName as AutomationEventName,
);
}