Skip to content

Commit 2f3bb93

Browse files
Harden Cursor post-review hook validation
1 parent 0066dea commit 2f3bb93

3 files changed

Lines changed: 339 additions & 14 deletions

File tree

‎hooks/post-review-context.mjs‎

Lines changed: 159 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
1+
import { appendFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2+
import { createHash } from "node:crypto";
23
import os from "node:os";
34
import path from "node:path";
45
import process from "node:process";
@@ -34,19 +35,158 @@ function isCodeRabbitReviewCommand(command) {
3435
if (typeof command !== "string") {
3536
return false;
3637
}
37-
return /coderabbit(\.exe)?(\s|.*\s)review(\s|$)/.test(command) && !/autofix/.test(command);
38+
39+
const argv = tokenizeSimpleCommand(command);
40+
if (!argv) {
41+
return false;
42+
}
43+
44+
const binary = path.basename(argv[0]).toLowerCase();
45+
return (
46+
["coderabbit", "coderabbit.exe", "cr", "cr.exe"].includes(binary) &&
47+
argv[1] === "review" &&
48+
argv.includes("--agent") &&
49+
!argv.includes("autofix")
50+
);
3851
}
3952

40-
function looksClean(toolOutput) {
41-
if (typeof toolOutput !== "string") {
53+
function tokenizeSimpleCommand(command) {
54+
const argv = [];
55+
let token = "";
56+
let quote = "";
57+
let escaped = false;
58+
let hasToken = false;
59+
60+
for (let index = 0; index < command.length; index += 1) {
61+
const char = command[index];
62+
63+
if (escaped) {
64+
token += char;
65+
hasToken = true;
66+
escaped = false;
67+
continue;
68+
}
69+
70+
if (char === "\\") {
71+
escaped = true;
72+
continue;
73+
}
74+
75+
if (quote) {
76+
if (char === quote) {
77+
quote = "";
78+
} else {
79+
token += char;
80+
hasToken = true;
81+
}
82+
continue;
83+
}
84+
85+
if (char === "'" || char === '"') {
86+
quote = char;
87+
hasToken = true;
88+
continue;
89+
}
90+
91+
if (char === "$" && command[index + 1] === "(") {
92+
return null;
93+
}
94+
95+
if (char === "#" || char === "\n" || char === "\r" || "|;&<>()`".includes(char)) {
96+
return null;
97+
}
98+
99+
if (/\s/.test(char)) {
100+
if (hasToken) {
101+
argv.push(token);
102+
token = "";
103+
hasToken = false;
104+
}
105+
continue;
106+
}
107+
108+
token += char;
109+
hasToken = true;
110+
}
111+
112+
if (escaped || quote) {
113+
return null;
114+
}
115+
116+
if (hasToken) {
117+
argv.push(token);
118+
}
119+
120+
return argv;
121+
}
122+
123+
function getExitCode(input) {
124+
const candidates = [
125+
input?.exit_code,
126+
input?.exitCode,
127+
input?.tool_exit_code,
128+
input?.tool_result?.exit_code,
129+
input?.tool_result?.exitCode,
130+
input?.tool_response?.exit_code,
131+
input?.tool_response?.exitCode,
132+
];
133+
134+
return candidates.find((candidate) => Number.isInteger(candidate));
135+
}
136+
137+
function toolSucceeded(input) {
138+
const exitCode = getExitCode(input);
139+
if (exitCode !== undefined && exitCode !== 0) {
42140
return false;
43141
}
44-
return /(raised|found|reported)\s+0\s+issues|"issues"\s*:\s*\[\s*\]|no issues found/i.test(toolOutput);
142+
143+
return !(input?.tool_error || input?.error);
144+
}
145+
146+
function reviewOutcome(toolOutput) {
147+
if (typeof toolOutput !== "string") {
148+
return null;
149+
}
150+
151+
let completeEvent = null;
152+
153+
for (const line of toolOutput.split(/\r?\n/)) {
154+
const trimmed = line.trim();
155+
if (!trimmed) {
156+
continue;
157+
}
158+
159+
let event;
160+
try {
161+
event = JSON.parse(trimmed);
162+
} catch {
163+
return null;
164+
}
165+
166+
if (event?.type === "error") {
167+
return null;
168+
}
169+
170+
if (event?.type === "complete") {
171+
completeEvent = event;
172+
}
173+
}
174+
175+
if (!completeEvent || !Number.isInteger(completeEvent.findings)) {
176+
return null;
177+
}
178+
179+
return completeEvent.findings === 0 ? "clean" : "issues";
45180
}
46181

47182
function statePath(input) {
48-
const key = String(input?.conversation_id || input?.generation_id || "global").replace(/[^A-Za-z0-9_-]/g, "");
49-
return path.join(os.tmpdir(), `coderabbit-clean-review-${key || "global"}.json`);
183+
const key = String(input?.conversation_id || input?.generation_id || "global");
184+
const digest = createHash("sha256").update(key).digest("hex").slice(0, 32);
185+
const baseDir =
186+
process.env.XDG_STATE_HOME || (os.homedir() ? path.join(os.homedir(), ".local", "state") : os.tmpdir());
187+
const dir = path.join(baseDir, "coderabbit", "cursor-plugin");
188+
mkdirSync(dir, { recursive: true, mode: 0o700 });
189+
return path.join(dir, `clean-review-${digest}.json`);
50190
}
51191

52192
function emit(context) {
@@ -67,20 +207,24 @@ const toolName = input?.tool_name ?? "unknown";
67207
const command = input?.tool_input?.command;
68208
const snippet = typeof command === "string" ? command.slice(0, 100) : "";
69209

70-
if (isCodeRabbitReviewCommand(command)) {
71-
const clean = looksClean(input?.tool_output);
72-
log(`review-complete tool=${toolName} clean=${clean} cmd=${snippet}`);
210+
if (isCodeRabbitReviewCommand(command) && toolSucceeded(input)) {
211+
const outcome = reviewOutcome(input?.tool_output);
212+
log(`review-complete tool=${toolName} outcome=${outcome ?? "unknown"} cmd=${snippet}`);
73213

74-
if (clean) {
214+
if (outcome === "clean") {
75215
try {
76-
writeFileSync(statePath(input), JSON.stringify({ expires: Date.now() + REMINDER_WINDOW_MS, remaining: REMINDER_MAX }));
216+
writeFileSync(
217+
statePath(input),
218+
JSON.stringify({ expires: Date.now() + REMINDER_WINDOW_MS, remaining: REMINDER_MAX }),
219+
{ mode: 0o600 },
220+
);
77221
} catch {
78222
// State is best-effort; the primary injection below still happens.
79223
}
80224
emit(
81225
"The CodeRabbit review for this request is complete and came back clean, meaning the changes passed review. Present a clean-result summary: what was reviewed (files changed, lines, scope), what it was checked for (bugs, security issues, code quality risks), confirmation that the changes passed, and suggested next steps such as running tests, committing, or opening a PR. Then finish the response there; the review request is fulfilled, so a second AI or manual pass over the same diff is not needed. Linters, type checkers, and tests remain available whenever the project workflow calls for them.",
82226
);
83-
} else {
227+
} else if (outcome === "issues") {
84228
try {
85229
unlinkSync(statePath(input));
86230
} catch {
@@ -89,6 +233,8 @@ if (isCodeRabbitReviewCommand(command)) {
89233
emit(
90234
"The CodeRabbit review for this request is complete. Present the parsed results grouped by severity and finish the response there; the review request is fulfilled. A second AI or manual pass over the same diff is not needed. Linters, type checkers, and tests remain available whenever the project workflow calls for them.",
91235
);
236+
} else {
237+
log(`pass unrecognized-agent-output tool=${toolName} cmd=${snippet}`);
92238
}
93239
process.exit(0);
94240
}

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"license": "MIT",
77
"type": "module",
88
"scripts": {
9-
"test": "node scripts/validate-plugin.mjs",
9+
"test": "node scripts/test-post-review-context.mjs && node scripts/validate-plugin.mjs",
1010
"validate": "node scripts/validate-plugin.mjs"
1111
},
1212
"engines": {

0 commit comments

Comments
 (0)