Skip to content

Commit a6888c0

Browse files
ashwin-antclaude
andauthored
feat: add time-based comment filtering to tag mode (#512)
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>
1 parent c041f89 commit a6888c0

5 files changed

Lines changed: 851 additions & 22 deletions

File tree

src/github/api/queries/github.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export const PR_QUERY = `
4646
login
4747
}
4848
createdAt
49+
updatedAt
50+
lastEditedAt
4951
isMinimized
5052
}
5153
}
@@ -59,6 +61,8 @@ export const PR_QUERY = `
5961
body
6062
state
6163
submittedAt
64+
updatedAt
65+
lastEditedAt
6266
comments(first: 100) {
6367
nodes {
6468
id
@@ -70,6 +74,8 @@ export const PR_QUERY = `
7074
login
7175
}
7276
createdAt
77+
updatedAt
78+
lastEditedAt
7379
isMinimized
7480
}
7581
}
@@ -100,6 +106,8 @@ export const ISSUE_QUERY = `
100106
login
101107
}
102108
createdAt
109+
updatedAt
110+
lastEditedAt
103111
isMinimized
104112
}
105113
}

src/github/data/fetcher.ts

Lines changed: 133 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { execFileSync } from "child_process";
22
import type { Octokits } from "../api/client";
33
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
4+
import {
5+
isIssueCommentEvent,
6+
isPullRequestReviewEvent,
7+
isPullRequestReviewCommentEvent,
8+
type ParsedGitHubContext,
9+
} from "../context";
410
import type {
511
GitHubComment,
612
GitHubFile,
@@ -13,12 +19,101 @@ import type {
1319
import type { CommentWithImages } from "../utils/image-downloader";
1420
import { downloadCommentImages } from "../utils/image-downloader";
1521

22+
/**
23+
* Extracts the trigger timestamp from the GitHub webhook payload.
24+
* This timestamp represents when the triggering comment/review/event was created.
25+
*
26+
* @param context - Parsed GitHub context from webhook
27+
* @returns ISO timestamp string or undefined if not available
28+
*/
29+
export function extractTriggerTimestamp(
30+
context: ParsedGitHubContext,
31+
): string | undefined {
32+
if (isIssueCommentEvent(context)) {
33+
return context.payload.comment.created_at || undefined;
34+
} else if (isPullRequestReviewEvent(context)) {
35+
return context.payload.review.submitted_at || undefined;
36+
} else if (isPullRequestReviewCommentEvent(context)) {
37+
return context.payload.comment.created_at || undefined;
38+
}
39+
40+
return undefined;
41+
}
42+
43+
/**
44+
* Filters comments to only include those that existed in their final state before the trigger time.
45+
* This prevents malicious actors from editing comments after the trigger to inject harmful content.
46+
*
47+
* @param comments - Array of GitHub comments to filter
48+
* @param triggerTime - ISO timestamp of when the trigger comment was created
49+
* @returns Filtered array of comments that were created and last edited before trigger time
50+
*/
51+
export function filterCommentsToTriggerTime<
52+
T extends { createdAt: string; updatedAt?: string; lastEditedAt?: string },
53+
>(comments: T[], triggerTime: string | undefined): T[] {
54+
if (!triggerTime) return comments;
55+
56+
const triggerTimestamp = new Date(triggerTime).getTime();
57+
58+
return comments.filter((comment) => {
59+
// Comment must have been created before trigger (not at or after)
60+
const createdTimestamp = new Date(comment.createdAt).getTime();
61+
if (createdTimestamp >= triggerTimestamp) {
62+
return false;
63+
}
64+
65+
// If comment has been edited, the most recent edit must have occurred before trigger
66+
// Use lastEditedAt if available, otherwise fall back to updatedAt
67+
const lastEditTime = comment.lastEditedAt || comment.updatedAt;
68+
if (lastEditTime) {
69+
const lastEditTimestamp = new Date(lastEditTime).getTime();
70+
if (lastEditTimestamp >= triggerTimestamp) {
71+
return false;
72+
}
73+
}
74+
75+
return true;
76+
});
77+
}
78+
79+
/**
80+
* Filters reviews to only include those that existed in their final state before the trigger time.
81+
* Similar to filterCommentsToTriggerTime but for GitHubReview objects which use submittedAt instead of createdAt.
82+
*/
83+
export function filterReviewsToTriggerTime<
84+
T extends { submittedAt: string; updatedAt?: string; lastEditedAt?: string },
85+
>(reviews: T[], triggerTime: string | undefined): T[] {
86+
if (!triggerTime) return reviews;
87+
88+
const triggerTimestamp = new Date(triggerTime).getTime();
89+
90+
return reviews.filter((review) => {
91+
// Review must have been submitted before trigger (not at or after)
92+
const submittedTimestamp = new Date(review.submittedAt).getTime();
93+
if (submittedTimestamp >= triggerTimestamp) {
94+
return false;
95+
}
96+
97+
// If review has been edited, the most recent edit must have occurred before trigger
98+
const lastEditTime = review.lastEditedAt || review.updatedAt;
99+
if (lastEditTime) {
100+
const lastEditTimestamp = new Date(lastEditTime).getTime();
101+
if (lastEditTimestamp >= triggerTimestamp) {
102+
return false;
103+
}
104+
}
105+
106+
return true;
107+
});
108+
}
109+
16110
type FetchDataParams = {
17111
octokits: Octokits;
18112
repository: string;
19113
prNumber: string;
20114
isPR: boolean;
21115
triggerUsername?: string;
116+
triggerTime?: string;
22117
};
23118

24119
export type GitHubFileWithSHA = GitHubFile & {
@@ -41,6 +136,7 @@ export async function fetchGitHubData({
41136
prNumber,
42137
isPR,
43138
triggerUsername,
139+
triggerTime,
44140
}: FetchDataParams): Promise<FetchDataResult> {
45141
const [owner, repo] = repository.split("/");
46142
if (!owner || !repo) {
@@ -68,7 +164,10 @@ export async function fetchGitHubData({
68164
const pullRequest = prResult.repository.pullRequest;
69165
contextData = pullRequest;
70166
changedFiles = pullRequest.files.nodes || [];
71-
comments = pullRequest.comments?.nodes || [];
167+
comments = filterCommentsToTriggerTime(
168+
pullRequest.comments?.nodes || [],
169+
triggerTime,
170+
);
72171
reviewData = pullRequest.reviews || [];
73172

74173
console.log(`Successfully fetched PR #${prNumber} data`);
@@ -88,7 +187,10 @@ export async function fetchGitHubData({
88187

89188
if (issueResult.repository.issue) {
90189
contextData = issueResult.repository.issue;
91-
comments = contextData?.comments?.nodes || [];
190+
comments = filterCommentsToTriggerTime(
191+
contextData?.comments?.nodes || [],
192+
triggerTime,
193+
);
92194

93195
console.log(`Successfully fetched issue #${prNumber} data`);
94196
} else {
@@ -141,25 +243,35 @@ export async function fetchGitHubData({
141243
body: c.body,
142244
}));
143245

144-
const reviewBodies: CommentWithImages[] =
145-
reviewData?.nodes
146-
?.filter((r) => r.body)
147-
.map((r) => ({
148-
type: "review_body" as const,
149-
id: r.databaseId,
150-
pullNumber: prNumber,
151-
body: r.body,
152-
})) ?? [];
153-
154-
const reviewComments: CommentWithImages[] =
155-
reviewData?.nodes
156-
?.flatMap((r) => r.comments?.nodes ?? [])
157-
.filter((c) => c.body && !c.isMinimized)
158-
.map((c) => ({
159-
type: "review_comment" as const,
160-
id: c.databaseId,
161-
body: c.body,
162-
})) ?? [];
246+
// Filter review bodies to trigger time
247+
const filteredReviewBodies = reviewData?.nodes
248+
? filterReviewsToTriggerTime(reviewData.nodes, triggerTime).filter(
249+
(r) => r.body,
250+
)
251+
: [];
252+
253+
const reviewBodies: CommentWithImages[] = filteredReviewBodies.map((r) => ({
254+
type: "review_body" as const,
255+
id: r.databaseId,
256+
pullNumber: prNumber,
257+
body: r.body,
258+
}));
259+
260+
// Filter review comments to trigger time
261+
const allReviewComments =
262+
reviewData?.nodes?.flatMap((r) => r.comments?.nodes ?? []) ?? [];
263+
const filteredReviewComments = filterCommentsToTriggerTime(
264+
allReviewComments,
265+
triggerTime,
266+
);
267+
268+
const reviewComments: CommentWithImages[] = filteredReviewComments
269+
.filter((c) => c.body && !c.isMinimized)
270+
.map((c) => ({
271+
type: "review_comment" as const,
272+
id: c.databaseId,
273+
body: c.body,
274+
}));
163275

164276
// Add the main issue/PR body if it has content
165277
const mainBody: CommentWithImages[] = contextData.body

src/github/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export type GitHubComment = {
1010
body: string;
1111
author: GitHubAuthor;
1212
createdAt: string;
13+
updatedAt?: string;
14+
lastEditedAt?: string;
1315
isMinimized?: boolean;
1416
};
1517

@@ -41,6 +43,8 @@ export type GitHubReview = {
4143
body: string;
4244
state: string;
4345
submittedAt: string;
46+
updatedAt?: string;
47+
lastEditedAt?: string;
4448
comments: {
4549
nodes: GitHubReviewComment[];
4650
};

src/modes/tag/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { createInitialComment } from "../../github/operations/comments/create-in
66
import { setupBranch } from "../../github/operations/branch";
77
import { configureGitAuth } from "../../github/operations/git-config";
88
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
9-
import { fetchGitHubData } from "../../github/data/fetcher";
9+
import {
10+
fetchGitHubData,
11+
extractTriggerTimestamp,
12+
} from "../../github/data/fetcher";
1013
import { createPrompt, generateDefaultPrompt } from "../../create-prompt";
1114
import { isEntityContext } from "../../github/context";
1215
import type { PreparedContext } from "../../create-prompt/types";
@@ -70,12 +73,15 @@ export const tagMode: Mode = {
7073
const commentData = await createInitialComment(octokit.rest, context);
7174
const commentId = commentData.id;
7275

76+
const triggerTime = extractTriggerTimestamp(context);
77+
7378
const githubData = await fetchGitHubData({
7479
octokits: octokit,
7580
repository: `${context.repository.owner}/${context.repository.repo}`,
7681
prNumber: context.entityNumber.toString(),
7782
isPR: context.isPR,
7883
triggerUsername: context.actor,
84+
triggerTime,
7985
});
8086

8187
// Setup branch

0 commit comments

Comments
 (0)