Skip to content

Harden Cursor post-review hook validation#1

Open
coderabbitai[bot] wants to merge 1 commit into
mainfrom
coderabbit/harden-post-review-context-hook
Open

Harden Cursor post-review hook validation#1
coderabbitai[bot] wants to merge 1 commit into
mainfrom
coderabbit/harden-post-review-context-hook

Conversation

@coderabbitai

@coderabbitai coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Summary

  • Harden hooks/post-review-context.mjs so it only injects review-complete context for a simple coderabbit or cr review --agent command shape.
  • Reject shell comments, chains, pipes, redirects, newlines, command substitution, unterminated quotes, and autofix commands when deciding whether this was a CodeRabbit review.
  • Trust structured --agent JSONL completion output instead of loose phrases such as CodeRabbit raised 0 issues.
  • Respect non-zero exit status fields when Cursor provides them.
  • Move clean-review reminder state from predictable /tmp/coderabbit-clean-review-*.json files to a per-user state directory with hashed keys and 0600 files.
  • Add regression tests for the reported spoof, valid clean reminders, findings output, chained commands, loose text, and failed exits.

Evidence And Reasoning

Pylon issue 25004 / 572363e0-6184-4996-93ad-35bf83cb4c31 reports that the Cursor plugin postToolUse hook can be tricked into injecting a clean CodeRabbit review result when the raw shell command merely contains # coderabbit review and the raw output contains a clean-looking phrase. PagerDuty incident Q30LPOMXDYA13A / #2175 later escalated the same Pylon issue through Support On-call Service at 2026-07-05T12:06:54Z with high urgency.

I reproduced the exact reporter PoC on main at 0066dea: the spoof command emitted the clean additional context, and a follow-up unrelated git status --short emitted the persisted clean-review reminder. The root cause is permissive regex validation over untrusted shell command text and output. A shell comment satisfies the command regex without invoking CodeRabbit, and a loose phrase satisfies the clean-result regex without structured CLI output.

The affected behavior was introduced in f24249d (Make CodeRabbit the default reviewer and prevent duplicate manual reviews), then remained present through 0066dea (Pre-publication cleanup). No matching GitHub issue, repository security advisory, or Linear issue was found for post-review-context, coderabbit-clean-review, additional_context, spoofable, or CWE-345.

Datadog log search for 2026-07-05T10:00:00Z through 2026-07-05T13:00:00Z found zero hits for coderabbitai/cursor-plugin, post-review-context.mjs, the PagerDuty incident ID, the Pylon issue UUID, and CWE-345. A broader .cursor-plugin/plugin.json search surfaced unrelated pr-reviewer-saas warnings against https://github.com/gbh-tech/ai-plugin/pull/49, so there is no evidence of a backend outage, traffic shift, dependency failure, or traceable production error for this local plugin hook path.

The CLI docs for cr review --agent state that agent mode writes JSONL events and includes a complete event with the final finding count, so the hook can key off structured completion output instead of natural-language output. The new command parser narrows the trusted command shape to coderabbit|cr review --agent and rejects shell syntax that would let extra commands or comments create misleading input.

Confidence: high for blocking the reported spoof path and the persisted false reminder. Residual risk: the hook still relies on Cursor's postToolUse payload fields because Cursor does not provide a separately authenticated executable identity in this repository. If Cursor later exposes richer hook metadata, the hook should prefer that over command text.

Verification

npm test

Result:

Post-review hook tests passed.
Cursor plugin validation passed.

GitHub validate check is passing on this PR.

Final Prompt

Triage PagerDuty incident Q30LPOMXDYA13A from Pylon issue 25004 as a production/security alert. Identify service, environment, time window, severity, symptom, customer impact, strongest evidence, likely cause, immediate mitigation, and follow-up steps. Check observability/infrastructure, recent deploys/config/commits, issue tracking, docs, and knowledge sources. If the code fix is clear from evidence, open or use the active PR and ensure the PR description includes evidence, reasoning, and confidence.

Final Plan

  • Start from the PagerDuty payload and live Pylon ticket.
  • Reproduce the reported Cursor plugin hook spoof on main.
  • Inspect connected repo history and existing remediation branch/PR.
  • Check Datadog, GitHub issues/advisories, Linear, and local KB for matching signals or prior incidents.
  • Validate the active remediation PR with local tests and GitHub checks.
  • Report concise incident status and next actions.

Initiative Context

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Author

Review Change Stack

📝 Walkthrough

Walkthrough

The post-review hook was rewritten to use safe command tokenization for detecting review tool invocations, structured JSON parsing of tool output to determine clean/issues outcomes, exit-code-based success checks, and hashed-digest state file naming under an XDG state directory. A new test script validates this behavior end-to-end.

Changes

Post-review hook rewrite and tests

Layer / File(s) Summary
Command tokenization and detection
hooks/post-review-context.mjs
Imports add mkdirSync and createHash; command strings are tokenized into argv to detect the review binary, require review/--agent, reject autofix, and reject unsafe shell constructs.
Tool success and outcome determination
hooks/post-review-context.mjs
New getExitCode/toolSucceeded interpret exit-code fields; reviewOutcome replaces the old heuristic by parsing newline-delimited JSON events and mapping findings to clean/issues.
State persistence and main control flow
hooks/post-review-context.mjs
State filenames are now hashed digests under an XDG-based state directory with restrictive permissions; main flow requires both command match and tool success before writing reminder state, otherwise clears stale state and logs unrecognized output.
End-to-end hook test script
scripts/test-post-review-context.mjs
New test script spawns the hook with controlled JSON inputs under a temp XDG state dir, asserting stdout/stderr behavior for clean, findings, spoofed, chained-command, and non-zero exit scenarios, plus reminder persistence.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Agent
  participant Hook as post-review-context.mjs
  participant ToolOutput as Tool Output (NDJSON)
  participant StateFile as State Directory

  Agent->>Hook: invoke with command + tool_output
  Hook->>Hook: tokenizeSimpleCommand(command)
  Hook->>Hook: detect review tool + --agent, reject autofix
  alt command recognized
    Hook->>Hook: toolSucceeded(exit code fields)
    Hook->>ToolOutput: parse NDJSON events
    ToolOutput-->>Hook: complete event with findings
    alt outcome == clean and tool succeeded
      Hook->>StateFile: write hashed digest reminder state
      Hook-->>Agent: additional_context (clean reminder)
    else
      Hook->>StateFile: clear stale clean state
      Hook-->>Agent: generic complete message
    end
  else
    Hook-->>Agent: log unrecognized output
  end
Loading

Estimated code review effort: 3 (Moderate) | ~25 minutes

Poem

A rabbit hops through shell and hash,
Tokenizing commands, avoiding crash,
Findings counted, clean or not,
State digests stored in their proper spot,
Tests all pass — hop, hop, done! 🐇✨

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change to the post-review hook validation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

CodeRabbit posted this review as a comment because GitHub doesn't allow pull request authors to request changes on their own pull requests.

Actionable comments posted: 3

🧹 Nitpick comments (2)
scripts/test-post-review-context.mjs (2)

163-175: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add negative reminder check for the failed-exit-code case.

The clean (Lines 79-106) and findings (Lines 108-135) blocks both follow up with a second runHook call to confirm reminder state was (not) persisted. The failed block only asserts the immediate output is empty but never confirms that no clean-review reminder state was written despite exit_code: 1. Without this, a regression that persists state on failed exits would go undetected.

✅ Suggested addition
     assert(failed === "", "failed review commands must not emit clean context");
+
+    const reminder = runHook(
+      {
+        conversation_id: "failed",
+        tool_name: "shell",
+        tool_input: { command: "git status --short" },
+        tool_output: "",
+      },
+      stateDir,
+    );
+    assert(reminder === "", "failed review commands must not persist clean review reminders");
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-post-review-context.mjs` around lines 163 - 175, The
failed-exit-code path in runHook is only checking that no clean context is
emitted, but it does not verify that reminder state is not persisted. Update the
failed block in test-post-review-context.mjs to add a second runHook/assertion
like the clean and findings cases, so it explicitly confirms no reminder state
is written when exit_code is 1. Use the existing withStateDir, runHook, and
failed test setup to mirror the negative persistence check.

54-176: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add coverage for the 0600 state-file permission hardening.

The PR objectives specifically call out moving reminder state to hashed, 0600-permission files as a security hardening measure, but no test in this suite verifies the resulting file's mode bits. Consider adding an assertion (e.g., via fs.statSync on the file created under stateDir after a successful clean-review call) to lock in this guarantee against regression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-post-review-context.mjs` around lines 54 - 176, The test suite
in runTests currently verifies reminder behavior but not the new state-file
hardening; add coverage that inspects the reminder file created under the
stateDir after a successful clean-review path. Use the existing clean-review
flow around runHook and parseHookOutput, then assert the created state file’s
permissions are 0600 (for example by statting the file under the hashed state
storage path), so the permission guarantee is locked in against regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@hooks/post-review-context.mjs`:
- Around line 38-51: `postReviewContext` should treat an empty token list from
`tokenizeSimpleCommand` the same as a missing parse result, because `[]`
currently bypasses the `if (!argv)` guard and leads to `path.basename(argv[0])`
throwing on undefined. Update the early-return check in
`isCodeRabbitReviewCommand` so it rejects both null and zero-length argv before
dereferencing `argv[0]`, preserving the hook’s “must never break” behavior for
empty or whitespace-only commands.
- Around line 182-189: The state directory creation in statePath(input) is
unguarded, so mkdirSync can abort the hook if the state location is unwritable.
Wrap the directory setup and path creation path in the existing best-effort
handling used by the post-review context flow, and make statePath(input) fall
back safely when mkdirSync fails so the review context can still be emitted.
- Around line 63-73: tokenizeSimpleCommand() is treating every backslash as an
escape, which breaks Windows path-qualified commands before the
coderabbit.exe/cr.exe allowlist check. Update the tokenization logic in
tokenizeSimpleCommand() to preserve or specially handle backslashes when they
appear as Windows path separators, so inputs like C:\Tools\coderabbit.exe review
--agent remain intact for command validation.

---

Nitpick comments:
In `@scripts/test-post-review-context.mjs`:
- Around line 163-175: The failed-exit-code path in runHook is only checking
that no clean context is emitted, but it does not verify that reminder state is
not persisted. Update the failed block in test-post-review-context.mjs to add a
second runHook/assertion like the clean and findings cases, so it explicitly
confirms no reminder state is written when exit_code is 1. Use the existing
withStateDir, runHook, and failed test setup to mirror the negative persistence
check.
- Around line 54-176: The test suite in runTests currently verifies reminder
behavior but not the new state-file hardening; add coverage that inspects the
reminder file created under the stateDir after a successful clean-review path.
Use the existing clean-review flow around runHook and parseHookOutput, then
assert the created state file’s permissions are 0600 (for example by statting
the file under the hashed state storage path), so the permission guarantee is
locked in against regressions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Enterprise

Run ID: 00b64575-2a35-4608-8874-c46a4ed9357c

📥 Commits

Reviewing files that changed from the base of the PR and between 0066dea and 2f3bb93.

⛔ Files ignored due to path filters (1)
  • package.json is excluded by !**/*.json
📒 Files selected for processing (2)
  • hooks/post-review-context.mjs
  • scripts/test-post-review-context.mjs
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • coderabbitai/bitbucket (manual)
📜 Review details
🔇 Additional comments (3)
scripts/test-post-review-context.mjs (2)

1-176: LGTM!


30-34: 🎯 Functional Correctness

No issue: CODERABBIT_HOOK_DEBUG is the hook’s debug gate, and 0 keeps stderr empty here.

			> Likely an incorrect or invalid review comment.
hooks/post-review-context.mjs (1)

123-180: LGTM!

Comment on lines +38 to 51

const argv = tokenizeSimpleCommand(command);
if (!argv) {
return false;
}

const binary = path.basename(argv[0]).toLowerCase();
return (
["coderabbit", "coderabbit.exe", "cr", "cr.exe"].includes(binary) &&
argv[1] === "review" &&
argv.includes("--agent") &&
!argv.includes("autofix")
);
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard against an empty token list before dereferencing argv[0].

tokenizeSimpleCommand returns an empty array [] for an empty or whitespace-only command (the loop never pushes a token). The guard at Line 40 (if (!argv)) only catches null, so [] passes through and path.basename(argv[0]) receives undefined, throwing TypeError [ERR_INVALID_ARG_TYPE]. Since this runs at module top level (Line 210), the exception crashes the hook — contradicting the "must never break the hook" intent. command === "" or " " from tool_input reaches this path.

🛡️ Proposed fix
   const argv = tokenizeSimpleCommand(command);
-  if (!argv) {
+  if (!argv || argv.length === 0) {
     return false;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const argv = tokenizeSimpleCommand(command);
if (!argv) {
return false;
}
const binary = path.basename(argv[0]).toLowerCase();
return (
["coderabbit", "coderabbit.exe", "cr", "cr.exe"].includes(binary) &&
argv[1] === "review" &&
argv.includes("--agent") &&
!argv.includes("autofix")
);
}
const argv = tokenizeSimpleCommand(command);
if (!argv || argv.length === 0) {
return false;
}
const binary = path.basename(argv[0]).toLowerCase();
return (
["coderabbit", "coderabbit.exe", "cr", "cr.exe"].includes(binary) &&
argv[1] === "review" &&
argv.includes("--agent") &&
!argv.includes("autofix")
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hooks/post-review-context.mjs` around lines 38 - 51, `postReviewContext`
should treat an empty token list from `tokenizeSimpleCommand` the same as a
missing parse result, because `[]` currently bypasses the `if (!argv)` guard and
leads to `path.basename(argv[0])` throwing on undefined. Update the early-return
check in `isCodeRabbitReviewCommand` so it rejects both null and zero-length
argv before dereferencing `argv[0]`, preserving the hook’s “must never break”
behavior for empty or whitespace-only commands.

Comment on lines +63 to +73
if (escaped) {
token += char;
hasToken = true;
escaped = false;
continue;
}

if (char === "\\") {
escaped = true;
continue;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for how the command is produced/invoked and any Windows path expectations in tests.
rg -nP -C3 '(coderabbit(\.exe)?|cr\.exe|--agent)' scripts/test-post-review-context.mjs
rg -nP -C3 'command' hooks/post-review-context.mjs

Repository: coderabbitai/cursor-plugin

Length of output: 2955


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== hooks/post-review-context.mjs (relevant ranges) =="
nl -ba hooks/post-review-context.mjs | sed -n '1,140p'

echo
echo "== scripts/test-post-review-context.mjs (relevant ranges) =="
nl -ba scripts/test-post-review-context.mjs | sed -n '1,240p'

echo
echo "== Search for basename and Windows path handling =="
rg -n "basename|win32|Windows|\\\\|coderabbit\\.exe|cr\\.exe" hooks scripts -S

Repository: coderabbitai/cursor-plugin

Length of output: 258


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== hooks/post-review-context.mjs (1-140) =="
sed -n '1,140p' hooks/post-review-context.mjs | cat -n

echo
echo "== scripts/test-post-review-context.mjs (1-240) =="
sed -n '1,240p' scripts/test-post-review-context.mjs | cat -n

echo
echo "== Search for basename and Windows path handling =="
rg -n "basename|win32|Windows|\\\\|coderabbit\\.exe|cr\\.exe" hooks scripts -S

Repository: coderabbitai/cursor-plugin

Length of output: 12087


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Windows/support mentions =="
rg -n "Windows|win32|cross-platform|path-qualified|\\.exe|coderabbit review --agent|cr review --agent" . -S

echo
echo "== Tokenizer behavior probe =="
node <<'JS'
const path = require('node:path');

function tokenizeSimpleCommand(command) {
  const argv = [];
  let token = "";
  let quote = "";
  let escaped = false;
  let hasToken = false;

  for (let index = 0; index < command.length; index += 1) {
    const char = command[index];
    if (escaped) { token += char; hasToken = true; escaped = false; continue; }
    if (char === "\\") { escaped = true; continue; }
    if (quote) { if (char === quote) { quote = ""; } else { token += char; hasToken = true; } continue; }
    if (char === "'" || char === '"') { quote = char; hasToken = true; continue; }
    if (char === "$" && command[index + 1] === "(") return null;
    if (char === "#" || char === "\n" || char === "\r" || "|;&<>()`".includes(char)) return null;
    if (/\s/.test(char)) { if (hasToken) { argv.push(token); token = ""; hasToken = false; } continue; }
    token += char; hasToken = true;
  }
  if (escaped || quote) return null;
  if (hasToken) argv.push(token);
  return argv;
}

for (const cmd of [
  String.raw`coderabbit review --agent`,
  String.raw`C:\Tools\coderabbit.exe review --agent`,
  String.raw`"C:\Tools\coderabbit.exe" review --agent`,
  String.raw`/home/user/.local/bin/cr review --agent`,
  String.raw`C:\\Tools\\coderabbit.exe review --agent`,
]) {
  const argv = tokenizeSimpleCommand(cmd);
  const binaryPosix = argv ? path.basename(argv[0]).toLowerCase() : null;
  const binaryWin32 = argv ? path.win32.basename(argv[0]).toLowerCase() : null;
  console.log(JSON.stringify({cmd, argv, binaryPosix, binaryWin32}, null, 2));
}
JS

Repository: coderabbitai/cursor-plugin

Length of output: 3139


Handle Windows path separators before tokenizing. tokenizeSimpleCommand() treats every \ as an escape, so a path-qualified invocation like C:\Tools\coderabbit.exe review --agent gets mangled before the coderabbit.exe/cr.exe allowlist check. If full Windows paths should be accepted, \ needs to be preserved or handled as a path separator here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hooks/post-review-context.mjs` around lines 63 - 73, tokenizeSimpleCommand()
is treating every backslash as an escape, which breaks Windows path-qualified
commands before the coderabbit.exe/cr.exe allowlist check. Update the
tokenization logic in tokenizeSimpleCommand() to preserve or specially handle
backslashes when they appear as Windows path separators, so inputs like
C:\Tools\coderabbit.exe review --agent remain intact for command validation.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the unshown region around line 242 to confirm error handling.
sed -n '190,260p' hooks/post-review-context.mjs

Repository: coderabbitai/cursor-plugin

Length of output: 3031


Wrap statePath(input) in the best-effort guard. mkdirSync can throw on an unwritable state dir, and this unguarded call will abort the hook before it emits the review context.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hooks/post-review-context.mjs` around lines 182 - 189, The state directory
creation in statePath(input) is unguarded, so mkdirSync can abort the hook if
the state location is unwritable. Wrap the directory setup and path creation
path in the existing best-effort handling used by the post-review context flow,
and make statePath(input) fall back safely when mkdirSync fails so the review
context can still be emitted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants