feat: add time-based comment filtering to tag mode

Implement time-based filtering for GitHub comments and reviews to prevent
malicious actors from editing existing comments after Claude is triggered
to inject harmful content.

Changes:
- Add updatedAt and lastEditedAt fields to GraphQL queries
- Update GitHubComment and GitHubReview types with timestamp fields
- Implement filterCommentsToTriggerTime() and filterReviewsToTriggerTime()
- Add extractTriggerTimestamp() to extract trigger time from webhooks
- Update tag and review modes to pass trigger timestamp to data fetcher

Security benefits:
- Prevents comment injection attacks via post-trigger edits
- Maintains chronological integrity of conversation context
- Ensures only comments in their final state before trigger are processed
- Backward compatible with graceful degradation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ashwin Bhat
2025-08-15 15:14:18 -07:00
parent c041f89493
commit 4fdc05dc2c
4 changed files with 151 additions and 21 deletions

View File

@@ -46,6 +46,8 @@ export const PR_QUERY = `
login login
} }
createdAt createdAt
updatedAt
lastEditedAt
isMinimized isMinimized
} }
} }
@@ -59,6 +61,8 @@ export const PR_QUERY = `
body body
state state
submittedAt submittedAt
updatedAt
lastEditedAt
comments(first: 100) { comments(first: 100) {
nodes { nodes {
id id
@@ -70,6 +74,8 @@ export const PR_QUERY = `
login login
} }
createdAt createdAt
updatedAt
lastEditedAt
isMinimized isMinimized
} }
} }
@@ -100,6 +106,8 @@ export const ISSUE_QUERY = `
login login
} }
createdAt createdAt
updatedAt
lastEditedAt
isMinimized isMinimized
} }
} }

View File

@@ -1,6 +1,12 @@
import { execFileSync } from "child_process"; import { execFileSync } from "child_process";
import type { Octokits } from "../api/client"; import type { Octokits } from "../api/client";
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github"; import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
import {
isIssueCommentEvent,
isPullRequestReviewEvent,
isPullRequestReviewCommentEvent,
type ParsedGitHubContext,
} from "../context";
import type { import type {
GitHubComment, GitHubComment,
GitHubFile, GitHubFile,
@@ -13,12 +19,101 @@ import type {
import type { CommentWithImages } from "../utils/image-downloader"; import type { CommentWithImages } from "../utils/image-downloader";
import { downloadCommentImages } from "../utils/image-downloader"; import { downloadCommentImages } from "../utils/image-downloader";
/**
* Extracts the trigger timestamp from the GitHub webhook payload.
* This timestamp represents when the triggering comment/review/event was created.
*
* @param context - Parsed GitHub context from webhook
* @returns ISO timestamp string or undefined if not available
*/
export function extractTriggerTimestamp(
context: ParsedGitHubContext,
): string | undefined {
if (isIssueCommentEvent(context)) {
return context.payload.comment.created_at || undefined;
} else if (isPullRequestReviewEvent(context)) {
return context.payload.review.submitted_at || undefined;
} else if (isPullRequestReviewCommentEvent(context)) {
return context.payload.comment.created_at || undefined;
}
return undefined;
}
/**
* Filters comments to only include those that existed in their final state before the trigger time.
* This prevents malicious actors from editing comments after the trigger to inject harmful content.
*
* @param comments - Array of GitHub comments to filter
* @param triggerTime - ISO timestamp of when the trigger comment was created
* @returns Filtered array of comments that were created and last edited before trigger time
*/
function filterCommentsToTriggerTime<
T extends { createdAt: string; updatedAt?: string; lastEditedAt?: string },
>(comments: T[], triggerTime: string | undefined): T[] {
if (!triggerTime) return comments;
const triggerTimestamp = new Date(triggerTime).getTime();
return comments.filter((comment) => {
// Comment must have been created before trigger
const createdTimestamp = new Date(comment.createdAt).getTime();
if (createdTimestamp > triggerTimestamp) {
return false;
}
// If comment has been edited, the most recent edit must have occurred before trigger
// Use lastEditedAt if available, otherwise fall back to updatedAt
const lastEditTime = comment.lastEditedAt || comment.updatedAt;
if (lastEditTime) {
const lastEditTimestamp = new Date(lastEditTime).getTime();
if (lastEditTimestamp > triggerTimestamp) {
return false;
}
}
return true;
});
}
/**
* Filters reviews to only include those that existed in their final state before the trigger time.
* Similar to filterCommentsToTriggerTime but for GitHubReview objects which use submittedAt instead of createdAt.
*/
function filterReviewsToTriggerTime<
T extends { submittedAt: string; updatedAt?: string; lastEditedAt?: string },
>(reviews: T[], triggerTime: string | undefined): T[] {
if (!triggerTime) return reviews;
const triggerTimestamp = new Date(triggerTime).getTime();
return reviews.filter((review) => {
// Review must have been submitted before trigger
const submittedTimestamp = new Date(review.submittedAt).getTime();
if (submittedTimestamp > triggerTimestamp) {
return false;
}
// If review has been edited, the most recent edit must have occurred before trigger
const lastEditTime = review.lastEditedAt || review.updatedAt;
if (lastEditTime) {
const lastEditTimestamp = new Date(lastEditTime).getTime();
if (lastEditTimestamp > triggerTimestamp) {
return false;
}
}
return true;
});
}
type FetchDataParams = { type FetchDataParams = {
octokits: Octokits; octokits: Octokits;
repository: string; repository: string;
prNumber: string; prNumber: string;
isPR: boolean; isPR: boolean;
triggerUsername?: string; triggerUsername?: string;
triggerTime?: string;
}; };
export type GitHubFileWithSHA = GitHubFile & { export type GitHubFileWithSHA = GitHubFile & {
@@ -41,6 +136,7 @@ export async function fetchGitHubData({
prNumber, prNumber,
isPR, isPR,
triggerUsername, triggerUsername,
triggerTime,
}: FetchDataParams): Promise<FetchDataResult> { }: FetchDataParams): Promise<FetchDataResult> {
const [owner, repo] = repository.split("/"); const [owner, repo] = repository.split("/");
if (!owner || !repo) { if (!owner || !repo) {
@@ -68,7 +164,10 @@ export async function fetchGitHubData({
const pullRequest = prResult.repository.pullRequest; const pullRequest = prResult.repository.pullRequest;
contextData = pullRequest; contextData = pullRequest;
changedFiles = pullRequest.files.nodes || []; changedFiles = pullRequest.files.nodes || [];
comments = pullRequest.comments?.nodes || []; comments = filterCommentsToTriggerTime(
pullRequest.comments?.nodes || [],
triggerTime,
);
reviewData = pullRequest.reviews || []; reviewData = pullRequest.reviews || [];
console.log(`Successfully fetched PR #${prNumber} data`); console.log(`Successfully fetched PR #${prNumber} data`);
@@ -88,7 +187,10 @@ export async function fetchGitHubData({
if (issueResult.repository.issue) { if (issueResult.repository.issue) {
contextData = issueResult.repository.issue; contextData = issueResult.repository.issue;
comments = contextData?.comments?.nodes || []; comments = filterCommentsToTriggerTime(
contextData?.comments?.nodes || [],
triggerTime,
);
console.log(`Successfully fetched issue #${prNumber} data`); console.log(`Successfully fetched issue #${prNumber} data`);
} else { } else {
@@ -141,25 +243,35 @@ export async function fetchGitHubData({
body: c.body, body: c.body,
})); }));
const reviewBodies: CommentWithImages[] = // Filter review bodies to trigger time
reviewData?.nodes const filteredReviewBodies = reviewData?.nodes
?.filter((r) => r.body) ? filterReviewsToTriggerTime(reviewData.nodes, triggerTime).filter(
.map((r) => ({ (r) => r.body,
type: "review_body" as const, )
id: r.databaseId, : [];
pullNumber: prNumber,
body: r.body,
})) ?? [];
const reviewComments: CommentWithImages[] = const reviewBodies: CommentWithImages[] = filteredReviewBodies.map((r) => ({
reviewData?.nodes type: "review_body" as const,
?.flatMap((r) => r.comments?.nodes ?? []) id: r.databaseId,
.filter((c) => c.body && !c.isMinimized) pullNumber: prNumber,
.map((c) => ({ body: r.body,
type: "review_comment" as const, }));
id: c.databaseId,
body: c.body, // Filter review comments to trigger time
})) ?? []; const allReviewComments =
reviewData?.nodes?.flatMap((r) => r.comments?.nodes ?? []) ?? [];
const filteredReviewComments = filterCommentsToTriggerTime(
allReviewComments,
triggerTime,
);
const reviewComments: CommentWithImages[] = filteredReviewComments
.filter((c) => c.body && !c.isMinimized)
.map((c) => ({
type: "review_comment" as const,
id: c.databaseId,
body: c.body,
}));
// Add the main issue/PR body if it has content // Add the main issue/PR body if it has content
const mainBody: CommentWithImages[] = contextData.body const mainBody: CommentWithImages[] = contextData.body

View File

@@ -10,6 +10,8 @@ export type GitHubComment = {
body: string; body: string;
author: GitHubAuthor; author: GitHubAuthor;
createdAt: string; createdAt: string;
updatedAt?: string;
lastEditedAt?: string;
isMinimized?: boolean; isMinimized?: boolean;
}; };
@@ -41,6 +43,8 @@ export type GitHubReview = {
body: string; body: string;
state: string; state: string;
submittedAt: string; submittedAt: string;
updatedAt?: string;
lastEditedAt?: string;
comments: { comments: {
nodes: GitHubReviewComment[]; nodes: GitHubReviewComment[];
}; };

View File

@@ -6,7 +6,10 @@ import { createInitialComment } from "../../github/operations/comments/create-in
import { setupBranch } from "../../github/operations/branch"; import { setupBranch } from "../../github/operations/branch";
import { configureGitAuth } from "../../github/operations/git-config"; import { configureGitAuth } from "../../github/operations/git-config";
import { prepareMcpConfig } from "../../mcp/install-mcp-server"; import { prepareMcpConfig } from "../../mcp/install-mcp-server";
import { fetchGitHubData } from "../../github/data/fetcher"; import {
fetchGitHubData,
extractTriggerTimestamp,
} from "../../github/data/fetcher";
import { createPrompt, generateDefaultPrompt } from "../../create-prompt"; import { createPrompt, generateDefaultPrompt } from "../../create-prompt";
import { isEntityContext } from "../../github/context"; import { isEntityContext } from "../../github/context";
import type { PreparedContext } from "../../create-prompt/types"; import type { PreparedContext } from "../../create-prompt/types";
@@ -70,12 +73,15 @@ export const tagMode: Mode = {
const commentData = await createInitialComment(octokit.rest, context); const commentData = await createInitialComment(octokit.rest, context);
const commentId = commentData.id; const commentId = commentData.id;
const triggerTime = extractTriggerTimestamp(context);
const githubData = await fetchGitHubData({ const githubData = await fetchGitHubData({
octokits: octokit, octokits: octokit,
repository: `${context.repository.owner}/${context.repository.repo}`, repository: `${context.repository.owner}/${context.repository.repo}`,
prNumber: context.entityNumber.toString(), prNumber: context.entityNumber.toString(),
isPR: context.isPR, isPR: context.isPR,
triggerUsername: context.actor, triggerUsername: context.actor,
triggerTime,
}); });
// Setup branch // Setup branch