-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cursor): capture file bytes on beforeReadFile when Cursor sends no content #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3624546
6c4a267
5dbc686
7f4a17f
ebdd622
9f6f4ec
32a97e7
404a10f
46a52c2
1a3e3f8
4211071
d1e70a1
2a4e621
bcbf210
052f6c6
0c31609
c4a251b
c9eb28c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -427,6 +427,104 @@ function Add-FilePreImage { | |
| } | ||
| } | ||
|
|
||
| # ── File read capture (beforeReadFile only), lockstep with hook.sh ───────── | ||
| # Cursor sends `beforeReadFile` with an empty `content` for some file types. | ||
| # Attach the file's own bytes as `rogueFileReadB64` so the request carries the | ||
| # file and not just its path. Over the cap a truncatable type is truncated and | ||
| # every other type is skipped, as the pre-image does. Every failure path | ||
| # returns the body unchanged. | ||
| $RogueFileReadMaxBytes = 1048576 | ||
|
|
||
| function Test-RogueReadCapturePath { | ||
| param([string]$Path) | ||
| if (-not $Path) { return $false } | ||
| $ext = [System.IO.Path]::GetExtension($Path) | ||
| if (-not $ext) { return $false } | ||
| return @('.pdf', '.svg') -contains $ext.ToLowerInvariant() | ||
| } | ||
|
|
||
| # Extensions whose bytes stay usable when they are cut short. An over-cap file | ||
| # NOT on this list is sent whole or not at all, as the pre-image does. | ||
| function Test-RogueReadCaptureTruncatable { | ||
| param([string]$Path) | ||
| if (-not $Path) { return $false } | ||
| $ext = [System.IO.Path]::GetExtension($Path) | ||
| if (-not $ext) { return $false } | ||
| return @('.svg') -contains $ext.ToLowerInvariant() | ||
| } | ||
|
|
||
| function Add-FileReadBytes { | ||
| # No ConvertTo-Json on the whole payload, as in Add-FilePreImage: a parse | ||
| # and reserialize could alter the vendor's JSON, and -Depth truncates. | ||
| param([string]$Body) | ||
| try { | ||
| # Get-RogueJsonStringField trims and the sh side's _json_string_field | ||
| # does not, so a whitespace-only content fires here but not there. | ||
| $content = Get-RogueJsonStringField $Body '.content' 'content' | ||
| if ($content) { return $Body } | ||
|
Comment on lines
+463
to
+464
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Use the untrimmed 🧰 Tools🪛 PSScriptAnalyzer (1.25.0)[warning] Missing BOM encoding for non-ASCII encoded file 'hook.ps1' (PSUseBOMForUnicodeEncodedFile) [info] 454-454: Cmdlet 'Get-RogueJsonStringField' has positional parameter. Please use named parameters instead of positional parameters when calling a command. (PSAvoidUsingPositionalParameters) 🤖 Prompt for AI Agents |
||
|
|
||
| $fp = Get-RogueJsonStringField $Body '.file_path // .tool_input.file_path' 'file_path' | ||
| if (-not $fp) { return $Body } | ||
| # Rooted paths only; a relative one would resolve against the hook's cwd. | ||
| # Looser than Add-FilePreImage's Windows-shaped test on purpose: an | ||
| # over-matching path here falls through to Test-Path and attaches | ||
| # nothing, where the pre-image would report a real file as absent. | ||
| if (-not [System.IO.Path]::IsPathRooted($fp)) { return $Body } | ||
| if (-not (Test-RogueReadCapturePath $fp)) { return $Body } | ||
| if (-not (Test-Path -LiteralPath $fp -PathType Leaf)) { return $Body } | ||
|
|
||
| $len = (Get-Item -LiteralPath $fp).Length | ||
| if ($len -le 0) { return $Body } | ||
| if ($len -gt $RogueFileReadMaxBytes) { | ||
| if (-not (Test-RogueReadCaptureTruncatable $fp)) { | ||
| Dbg "read capture $len B over cap -> sending none" | ||
| return $Body | ||
| } | ||
| Dbg "read capture $len B -> truncating to $RogueFileReadMaxBytes" | ||
| } | ||
| $take = [int][Math]::Min([int64]$len, [int64]$RogueFileReadMaxBytes) | ||
| # Streamed rather than ReadAllBytes so an over-cap file is not fully | ||
| # loaded just to discard most of it. | ||
| $buf = New-Object byte[] $take | ||
| $read = 0 | ||
| # FileShare ReadWrite, as in Add-FilePreImage. This fires on a READ, so | ||
| # the editor is very likely holding the file and the default share mode | ||
| # would throw and lose the capture. | ||
| $fs = [System.IO.File]::Open($fp, 'Open', 'Read', 'ReadWrite') | ||
| try { | ||
| while ($read -lt $take) { | ||
| $n = $fs.Read($buf, $read, $take - $read) | ||
| if ($n -le 0) { break } | ||
| $read += $n | ||
| } | ||
| } finally { $fs.Dispose() } | ||
| if ($read -le 0) { return $Body } | ||
| # A range index yields Object[] and ToBase64String takes byte[]. Cast | ||
| # rather than rely on coercion, since 5.1 is the shipping runtime. | ||
| if ($read -lt $take) { $buf = [byte[]]$buf[0..($read - 1)] } | ||
| $b64 = [Convert]::ToBase64String($buf) | ||
| if (-not $b64) { return $Body } | ||
| Dbg "read capture attached for $fp ($($b64.Length) b64 chars)" | ||
|
|
||
| # jq-or-concat, as in Add-FilePreImage. The base64 goes to jq as one | ||
| # argument, and Windows caps a command line at 32,767 characters | ||
| # (~24 KiB of file), so Invoke-RogueJq yields nothing and the concat | ||
| # below is what runs. It is not dead code. | ||
| $out = Invoke-RogueJq $Body @('-c', '--arg', 'b64', $b64, '. + {rogueFileReadB64:$b64}') | ||
| if ($out -and $out.StartsWith('{') -and $out.EndsWith('}')) { return $out } | ||
|
|
||
| $trimmed = $Body.TrimEnd() | ||
| if (-not $trimmed.EndsWith('}')) { return $Body } | ||
| $p = $trimmed.Substring(0, $trimmed.Length - 1) | ||
| $sep = ',' | ||
| if ($p -eq '{') { $sep = '' } | ||
| return $p + $sep + '"rogueFileReadB64":"' + $b64 + '"}' | ||
| } catch { | ||
| Dbg "read capture failed: $($_.Exception.Message)" | ||
| return $Body | ||
| } | ||
| } | ||
|
|
||
| # ── Subagent -> parent session attribution — lockstep with hook.sh ───────── | ||
| # A Cursor subagent's preToolUse / postToolUse / afterFileEdit / | ||
| # beforeShellExecution all arrive with conversation_id == session_id == THE | ||
|
|
@@ -786,10 +884,10 @@ $payload = $payload.TrimStart([char]0xFEFF) | |
| # which happens on clients with a non-UTF-8 Windows locale (out of our control). | ||
| $payload = Repair-DoubleEncodedUtf8 $payload | ||
|
|
||
| # File pre-image (see Add-FilePreImage) — the one place this dispatcher adds to | ||
| # the vendor payload. It only ever appends a field; a failure leaves the body | ||
| # byte-identical. | ||
| # The two places this dispatcher adds to the vendor payload. Both only ever | ||
| # append a field; a failure leaves the body byte-identical. | ||
| if ($EventName -eq 'preToolUse') { $payload = Add-FilePreImage $payload } | ||
| if ($EventName -eq 'beforeReadFile') { $payload = Add-FileReadBytes $payload } | ||
|
|
||
| # Only the events a subagent actually fires resolve. sessionStart / sessionEnd / | ||
| # subagentStart / subagentStop are parent-side: they already carry the parent's | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -381,10 +381,88 @@ augment_with_pre_image() { | |
| printf '%s%s"rogueFilePreImageB64":"%s"}' "$_pre" "$_sep" "$_b64" | ||
| } | ||
|
|
||
| # ── File read capture (beforeReadFile only) ──────────────────────────────── | ||
| # Cursor sends `beforeReadFile` with an empty `content` for some file types. | ||
| # Attach the file's own bytes as `rogueFileReadB64` so the request carries the | ||
| # file and not just its path. Over the cap a truncatable type is truncated and | ||
| # every other type is skipped, as the pre-image does. Every failure path | ||
| # returns the body unchanged. | ||
| READ_CAPTURE_MAX_BYTES=1048576 | ||
|
|
||
| _is_read_capture_path() { | ||
| _rc_base=$(printf '%s' "${1##*/}" | tr '[:upper:]' '[:lower:]') | ||
| case "$_rc_base" in | ||
| *.pdf|*.svg) return 0 ;; | ||
| esac | ||
| return 1 | ||
| } | ||
|
|
||
| # Extensions whose bytes stay usable when they are cut short. An over-cap file | ||
| # NOT on this list is sent whole or not at all, as the pre-image does. | ||
| _is_read_capture_truncatable() { | ||
| _rct_base=$(printf '%s' "${1##*/}" | tr '[:upper:]' '[:lower:]') | ||
| case "$_rct_base" in | ||
| *.svg) return 0 ;; | ||
| esac | ||
| return 1 | ||
| } | ||
|
|
||
| augment_with_file_read() { | ||
| _body="$1" | ||
| # A non-empty content means the payload already carries the file. jq's `//` | ||
| # and the fallback scan both yield "" for `"content":""`. hook.ps1 trims and | ||
| # this does not, so a whitespace-only content fires there but not here. | ||
| _rc_content="$(_json_string_field "$_body" '.content' content)" | ||
| [ -z "$_rc_content" ] || { printf '%s' "$_body"; return; } | ||
|
|
||
| _rc_fp="$(_json_string_field "$_body" '.file_path // .tool_input.file_path' file_path)" | ||
| # Absolute paths only; a relative one would resolve against the hook's cwd. | ||
| case "$_rc_fp" in /*) : ;; *) printf '%s' "$_body"; return ;; esac | ||
| # A backslash means the fallback scan did not unescape the value. hook.ps1 | ||
| # unescapes instead, the same divergence as the pre-image. | ||
| case "$_rc_fp" in *\\*) printf '%s' "$_body"; return ;; esac | ||
| _is_read_capture_path "$_rc_fp" || { printf '%s' "$_body"; return; } | ||
|
|
||
| { [ -f "$_rc_fp" ] && [ -r "$_rc_fp" ]; } || { printf '%s' "$_body"; return; } | ||
| _rc_sz=$(wc -c < "$_rc_fp" 2>/dev/null | tr -d ' ') | ||
| case "$_rc_sz" in ''|*[!0-9]*) printf '%s' "$_body"; return ;; esac | ||
| [ "$_rc_sz" -gt 0 ] || { printf '%s' "$_body"; return; } | ||
| if [ "$_rc_sz" -gt "$READ_CAPTURE_MAX_BYTES" ]; then | ||
| _is_read_capture_truncatable "$_rc_fp" || { | ||
| dbg "read capture $_rc_sz B over cap -> sending none" | ||
| printf '%s' "$_body"; return | ||
| } | ||
| dbg "read capture $_rc_sz B -> truncating to $READ_CAPTURE_MAX_BYTES" | ||
| fi | ||
| _rc_b64=$(head -c "$READ_CAPTURE_MAX_BYTES" "$_rc_fp" 2>/dev/null | base64 2>/dev/null | tr -d '\r\n') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/sh
set -eu
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
mkdir "$tmp/bin"
cat > "$tmp/bin/head" <<'EOF'
#!/bin/sh
printf 'x'
exit 1
EOF
chmod +x "$tmp/bin/head"
result=$(
PATH="$tmp/bin:$PATH" sh -c '
value=$(head -c 1 ignored | base64 | tr -d "\r\n")
printf "%s|%s\n" "$?" "$value"
'
)
test "$result" = '0|eA=='
printf 'upstream failure was masked: %s\n' "$result"Repository: rogue-security/rogue-plugins Length of output: 202 🏁 Script executed: #!/bin/sh
set -eu
printf '%s\n' '--- target section ---'
sed -n '360,455p' plugins/cursor/scripts/hook.sh
printf '%s\n' '--- related identifiers ---'
rg -n -C 3 '_rc_b64|rogueFileReadB64|_body|READ_CAPTURE_MAX_BYTES' plugins/cursor/scripts/hook.shRepository: rogue-security/rogue-plugins Length of output: 11957 Preserve The pipeline at line 429 reports only 🤖 Prompt for AI Agents |
||
| [ -n "$_rc_b64" ] || { printf '%s' "$_body"; return; } | ||
| dbg "read capture attached for $_rc_fp (${#_rc_b64} b64 chars)" | ||
|
|
||
| # jq when it is on PATH, else strip the trailing `}`, append, re-close. | ||
| # base64 has no JSON-special characters, so the concat is safe. The base64 | ||
| # goes to jq as one argument, so past the platform's argv limit jq cannot be | ||
| # exec'd (~96 KiB of file on Linux, ~770 KiB on macOS) and the concat is what | ||
| # runs. It is not dead code. | ||
| if command -v jq >/dev/null 2>&1; then | ||
| _rc_out=$(printf '%s' "$_body" | jq -c --arg b64 "$_rc_b64" \ | ||
| '. + {rogueFileReadB64:$b64}' 2>/dev/null) | ||
| case "$_rc_out" in '{'*'}') printf '%s' "$_rc_out"; return ;; esac | ||
| fi | ||
| _rc_trimmed="${_body%"${_body##*[![:space:]]}"}" | ||
| case "$_rc_trimmed" in *'}') : ;; *) printf '%s' "$_body"; return ;; esac | ||
| _rc_pre="${_rc_trimmed%\}}" | ||
| if [ "$_rc_pre" = "{" ]; then _rc_sep=""; else _rc_sep=","; fi | ||
| printf '%s%s"rogueFileReadB64":"%s"}' "$_rc_pre" "$_rc_sep" "$_rc_b64" | ||
| } | ||
|
|
||
| if [ "$event" = "preToolUse" ]; then | ||
| PAYLOAD="$(augment_with_pre_image "$PAYLOAD")" | ||
| fi | ||
|
|
||
| if [ "$event" = "beforeReadFile" ]; then | ||
| PAYLOAD="$(augment_with_file_read "$PAYLOAD")" | ||
| fi | ||
|
|
||
| # ── Subagent -> parent session attribution (headers only) ────────────────── | ||
| # A Cursor subagent's preToolUse / postToolUse / afterFileEdit / | ||
| # beforeShellExecution all fire hooks and all arrive with | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Replace the em dash with ASCII text.
Line 425 contains an em dash in PowerShell source. Use a hyphen instead.
As per coding guidelines, "No em dash, en dash, arrow or smart quote in PowerShell CODE."
🧰 Tools
🪛 PSScriptAnalyzer (1.25.0)
[warning] Missing BOM encoding for non-ASCII encoded file 'hook.ps1'
(PSUseBOMForUnicodeEncodedFile)
🤖 Prompt for AI Agents
Source: Coding guidelines