mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-22 03:18:54 +08:00
Check collaborator permissions for workflow_run events (#1590)
The write-permission gate previously only ran for issue/PR entity events. Apply it to workflow_run events as well, checking both the workflow actor and the actor recorded on the upstream run when they differ. allowed_non_write_users and the github_token override behave the same as for entity events. Document the behavior for workflow_run pipelines.
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
isIssuesAssignedEvent,
|
||||
isEntityContext,
|
||||
isAutomationContext,
|
||||
isWorkflowRunEvent,
|
||||
} from "../src/github/context";
|
||||
import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "../src/github/constants";
|
||||
import { createMockContext, createMockAutomationContext } from "./mockContext";
|
||||
@@ -517,4 +518,14 @@ describe("type guards", () => {
|
||||
).toBe(true);
|
||||
expect(isAutomationContext(issuesContext)).toBe(false);
|
||||
});
|
||||
|
||||
test("isWorkflowRunEvent accepts only workflow_run", () => {
|
||||
expect(
|
||||
isWorkflowRunEvent(
|
||||
createMockAutomationContext({ eventName: "workflow_run" }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isWorkflowRunEvent(workflowDispatchContext)).toBe(false);
|
||||
expect(isWorkflowRunEvent(issuesContext)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as core from "@actions/core";
|
||||
import { checkWritePermissions } from "../src/github/validation/permissions";
|
||||
import type { ParsedGitHubContext } from "../src/github/context";
|
||||
import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "../src/github/constants";
|
||||
import { createMockAutomationContext } from "./mockContext";
|
||||
|
||||
describe("checkWritePermissions", () => {
|
||||
let coreInfoSpy: any;
|
||||
@@ -455,4 +456,159 @@ describe("checkWritePermissions", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow_run contexts", () => {
|
||||
const createWorkflowRunContext = (
|
||||
actor: string,
|
||||
runActor: string = actor,
|
||||
) =>
|
||||
createMockAutomationContext({
|
||||
eventName: "workflow_run",
|
||||
eventAction: "completed",
|
||||
actor,
|
||||
payload: {
|
||||
action: "completed",
|
||||
workflow_run: {
|
||||
id: 123,
|
||||
event: "pull_request",
|
||||
actor: { login: runActor },
|
||||
head_repository: { full_name: "fork-owner/test-repo" },
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const createMockOctokitWithLevels = (levels: Record<string, string>) =>
|
||||
({
|
||||
repos: {
|
||||
getCollaboratorPermissionLevel: async (params: {
|
||||
username: string;
|
||||
}) => ({
|
||||
data: { permission: levels[params.username] ?? "none" },
|
||||
}),
|
||||
},
|
||||
}) as any;
|
||||
|
||||
test("should return false when the run actor lacks write access", async () => {
|
||||
const mockOctokit = createMockOctokit("read");
|
||||
const context = createWorkflowRunContext("fork-contributor");
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(coreWarningSpy).toHaveBeenCalledWith(
|
||||
"Actor has insufficient permissions: read",
|
||||
);
|
||||
});
|
||||
|
||||
test("should return true when the run actor has write access", async () => {
|
||||
const mockOctokit = createMockOctokit("write");
|
||||
const context = createWorkflowRunContext("maintainer");
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true when the run actor has admin access", async () => {
|
||||
const mockOctokit = createMockOctokit("admin");
|
||||
const context = createWorkflowRunContext("maintainer");
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should also check the payload run actor when it differs from the workflow actor", async () => {
|
||||
const mockOctokit = createMockOctokitWithLevels({
|
||||
maintainer: "write",
|
||||
"fork-contributor": "read",
|
||||
});
|
||||
const context = createWorkflowRunContext(
|
||||
"maintainer",
|
||||
"fork-contributor",
|
||||
);
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(coreInfoSpy).toHaveBeenCalledWith(
|
||||
"workflow_run was started by fork-contributor; checking permissions for that actor as well",
|
||||
);
|
||||
});
|
||||
|
||||
test("should return true when both the workflow actor and run actor have write access", async () => {
|
||||
const mockOctokit = createMockOctokitWithLevels({
|
||||
maintainer: "write",
|
||||
"other-maintainer": "admin",
|
||||
});
|
||||
const context = createWorkflowRunContext(
|
||||
"maintainer",
|
||||
"other-maintainer",
|
||||
);
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should allow a run actor listed in allowed_non_write_users when github_token is provided", async () => {
|
||||
const mockOctokit = createMockOctokit("read");
|
||||
const context = createWorkflowRunContext("fork-contributor");
|
||||
|
||||
const result = await checkWritePermissions(
|
||||
mockOctokit,
|
||||
context,
|
||||
"fork-contributor,other-user",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(coreWarningSpy).toHaveBeenCalledWith(
|
||||
"⚠️ SECURITY WARNING: Bypassing write permission check for fork-contributor due to allowed_non_write_users configuration. This should only be used for workflows with very limited permissions.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should NOT bypass for a run actor in allowed_non_write_users when github_token is not provided", async () => {
|
||||
const mockOctokit = createMockOctokit("read");
|
||||
const context = createWorkflowRunContext("fork-contributor");
|
||||
|
||||
const result = await checkWritePermissions(
|
||||
mockOctokit,
|
||||
context,
|
||||
"fork-contributor",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(coreWarningSpy).toHaveBeenCalledWith(
|
||||
"Actor has insufficient permissions: read",
|
||||
);
|
||||
});
|
||||
|
||||
test("should require the payload run actor to also be in allowed_non_write_users", async () => {
|
||||
const mockOctokit = createMockOctokit("read");
|
||||
const context = createWorkflowRunContext(
|
||||
"maintainer",
|
||||
"fork-contributor",
|
||||
);
|
||||
|
||||
const result = await checkWritePermissions(
|
||||
mockOctokit,
|
||||
context,
|
||||
"maintainer",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for [bot] run actors", async () => {
|
||||
const mockOctokit = createMockOctokit("none");
|
||||
const context = createWorkflowRunContext("dependabot[bot]");
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user