fix(prd-7): M3 security review — self-trigger guard, actor permissions, trigger bypass, platform validation

- Add self-mention/bot loop prevention in actor.ts (blocks actor matching bot_name)
- Switch Forgejo permission check to actor-level via getCollaboratorPermissionLevel
  with fallback to token-level repos.get() when collaborator API returns 403
- Close empty-body PR review comment trigger bypass (now returns false instead of true)
- Add platform detection cross-validation (reject PLATFORM=forgejo on github.com)
- Document URL builder defense-in-depth rationale in comment-logic.ts
- Update/add tests across 6 test files (actor, permissions, trigger, platform, mcp, m7)

Progress: 50% complete (3/6 milestones) — next: M4 test coverage review

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vlad MocanuandClaude Opus 4.6 committed 2026-02-26 12:31:12 +02:00
1 parent cefc229199
commit 6b0302d226
12 files changed
+270 -68

No files matched your search

+69 -51
View File
@@ -316,6 +316,8 @@ describe("checkWritePermissions", () => {
savedForgejo = process.env.FORGEJO;
savedServerUrl = process.env.GITHUB_SERVER_URL;
process.env.PLATFORM = "forgejo";
// Ensure cross-validation doesn't reject PLATFORM=forgejo
process.env.GITHUB_SERVER_URL = "https://git.example.com";
});
afterEach(() => {
@@ -329,25 +331,75 @@ describe("checkWritePermissions", () => {
_resetPlatformCache();
});
const createForgejoOctokit = (permissions: {
// Helper: Forgejo octokit where collaborator API works (actor-level check)
const createForgejoActorOctokit = (permission: string) => {
return {
repos: {
getCollaboratorPermissionLevel: async () => ({
data: { permission },
}),
get: async () => {
throw new Error("Should not fall back to repos.get");
},
},
} as any;
};
// Helper: Forgejo octokit where collaborator API fails (falls back to token-level)
const createForgejoFallbackOctokit = (permissions: {
admin?: boolean;
push?: boolean;
pull?: boolean;
}) => {
return {
repos: {
getCollaboratorPermissionLevel: async () => {
throw new Error("403 Forbidden");
},
get: async () => ({
data: { permissions },
}),
getCollaboratorPermissionLevel: async () => {
throw new Error("Should not be called on Forgejo");
},
},
} as any;
};
test("should return true when Forgejo token has push access", async () => {
const mockOctokit = createForgejoOctokit({
test("should use actor-level permission check on Forgejo (write)", async () => {
const mockOctokit = createForgejoActorOctokit("write");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
expect(coreInfoSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Forgejo actor 'test-user' permission level: write",
),
);
});
test("should use actor-level permission check on Forgejo (admin)", async () => {
const mockOctokit = createForgejoActorOctokit("admin");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
});
test("should reject actor with read-only permission on Forgejo", async () => {
const mockOctokit = createForgejoActorOctokit("read");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("insufficient permissions: read"),
);
});
test("should fall back to token-level check when collaborator API fails", async () => {
const mockOctokit = createForgejoFallbackOctokit({
admin: false,
push: true,
pull: true,
@@ -357,26 +409,16 @@ describe("checkWritePermissions", () => {
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
expect(coreWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("Falling back to token-level permission check"),
);
expect(coreInfoSpy).toHaveBeenCalledWith(
expect.stringContaining("Forgejo token has write access"),
expect.stringContaining("token-level fallback"),
);
});
test("should return true when Forgejo token has admin access", async () => {
const mockOctokit = createForgejoOctokit({
admin: true,
push: false,
pull: true,
});
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
});
test("should return false when Forgejo token has only pull access", async () => {
const mockOctokit = createForgejoOctokit({
test("should return false on fallback when token has only pull access", async () => {
const mockOctokit = createForgejoFallbackOctokit({
admin: false,
push: false,
pull: true,
@@ -386,14 +428,14 @@ describe("checkWritePermissions", () => {
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("insufficient permissions"),
);
});
test("should return false when permissions object is missing", async () => {
test("should return false on fallback when permissions object is missing", async () => {
const mockOctokit = {
repos: {
getCollaboratorPermissionLevel: async () => {
throw new Error("403 Forbidden");
},
get: async () => ({ data: {} }),
},
} as any;
@@ -407,32 +449,8 @@ describe("checkWritePermissions", () => {
);
});
test("should not call getCollaboratorPermissionLevel on Forgejo", async () => {
let collaboratorCalled = false;
const mockOctokit = {
repos: {
get: async () => ({
data: { permissions: { admin: false, push: true, pull: true } },
}),
getCollaboratorPermissionLevel: async () => {
collaboratorCalled = true;
return { data: { permission: "write" } };
},
},
} as any;
const context = createContext();
await checkWritePermissions(mockOctokit, context);
expect(collaboratorCalled).toBe(false);
});
test("should still bypass for allowed_non_write_users on Forgejo", async () => {
const mockOctokit = createForgejoOctokit({
admin: false,
push: false,
pull: true,
});
const mockOctokit = createForgejoActorOctokit("read");
const context = createContext();
const result = await checkWritePermissions(