diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 1f228cb..7cf1317 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -11,7 +11,7 @@ "plugins": [ { "name": "rogue-security", - "version": "1.1.4", + "version": "1.1.5", "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", "author": { "name": "Rogue Security", diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 6d2d201..b33042d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -89,10 +89,11 @@ jobs: [ "$fail" = 0 ] || exit 1 - name: Shell unit tests - # Only the dependency-free ones run here (the dispatcher end-to-end tests - # need a mock server + nc). dash is Ubuntu's /bin/sh, i.e. what Claude Code - # invokes the hook with, so the actor cascade is exercised under strict - # POSIX. Its PowerShell twin is covered by the unit tests below. + # Mostly the dependency-free ones, plus the Cursor dispatcher end-to-end + # suite, whose python3 mock server the runner already has. dash is + # Ubuntu's /bin/sh, i.e. what Claude Code invokes the hook with, so the + # actor cascade and the dispatcher both run under strict POSIX. Their + # PowerShell twins are covered by the unit tests below. run: | set -euo pipefail TEST_SH=dash bash tests/test_actor_sh.sh @@ -113,6 +114,8 @@ jobs: bash tests/test_gitignore_bundles.sh TEST_SH=dash bash tests/test_setup_env.sh TEST_SH=bash bash tests/test_setup_env.sh + SH=bash bash tests/test_hook_sh_cursor.sh + TEST_SH=dash bash tests/test_hook_sh_cursor.sh - name: Kiro installer (temp HOME, fake kiro-cli) # install.sh --kiro is the only installer that WRITES the vendor's hook # wiring itself (a hook file, Crew wrappers, a merge into every agent @@ -394,6 +397,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit 1 } powershell -NoProfile -File tests/test_hook_ps1_copilot.ps1 if ($LASTEXITCODE -ne 0) { exit 1 } + powershell -NoProfile -File tests/test_hook_ps1_cursor.ps1 + if ($LASTEXITCODE -ne 0) { exit 1 } powershell -NoProfile -File tests/test_hook_ps1_antigravity.ps1 if ($LASTEXITCODE -ne 0) { exit 1 } powershell -NoProfile -File tests/test_hook_ps1_kiro.ps1 diff --git a/plugins/cursor/.cursor-plugin/plugin.json b/plugins/cursor/.cursor-plugin/plugin.json index 20c1bb3..1d7ad53 100644 --- a/plugins/cursor/.cursor-plugin/plugin.json +++ b/plugins/cursor/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "Rogue Security", - "version": "1.1.4", + "version": "1.1.5", "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", "author": { "name": "rogue-security", diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index ca376a0..0742223 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -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 } + + $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 diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index 93ace94..daab093 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -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') + [ -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 diff --git a/tests/test_hook_ps1_cursor.ps1 b/tests/test_hook_ps1_cursor.ps1 index 04964ac..2bf78a7 100644 --- a/tests/test_hook_ps1_cursor.ps1 +++ b/tests/test_hook_ps1_cursor.ps1 @@ -236,7 +236,14 @@ $job = Start-Job -ScriptBlock { [System.IO.File]::WriteAllText([System.IO.Path]::Combine($dir, ($child + '.jsonl')), '') } -ArgumentList $h, $SLUG, $parentW, $childW $r = Resolve-RogueParentSession (New-Payload $childW) -Receive-Job $job -Wait -AutoRemoveJob | Out-Null +# Wait, then discard. Receive-Job is NOT called: on the Windows PowerShell 5.1 +# runner it throws "The Persistence Path does not exist." whatever arguments it +# is given, and $ErrorActionPreference = 'Stop' turns that into a dead suite. +# Nothing here needs the job's output, only its side effect (the file), which +# the assertions below cover. Start-Job and Wait-Job are fine; teardown is +# best-effort so a job-subsystem quirk can never fail a passing test. +Wait-Job $job | Out-Null +try { Remove-Job $job -Force -ErrorAction SilentlyContinue } catch { } Assert-Eq $r.Parent $parentW 'live marker: waited and resolved once the file appeared' Assert-Eq $r.Child $childW 'mid-wait resolution carries the child id' @@ -246,6 +253,137 @@ Assert-Null (Resolve-RogueParentSession '{"conversation_id":"../../etc/passwd"}' Assert-Null (Resolve-RogueParentSession 'not json at all') 'an unparseable payload resolves to nothing' Assert-Null (Resolve-RogueParentSession '{}') 'a payload with no conversation_id resolves to nothing' +# --- File read capture (beforeReadFile) ----------------------------------- +# Add-FileReadBytes attaches the file's own bytes as rogueFileReadB64 when the +# payload's `content` is empty. Same lockstep rule as the block above: every +# case here has a case in tests/test_hook_sh_cursor.sh. + +# Extension allowlist. +Assert-True (Test-RogueReadCapturePath '/tmp/a.pdf') 'pdf is captured' +Assert-True (Test-RogueReadCapturePath '/tmp/A.PDF') 'the extension test is case-insensitive' +Assert-True (Test-RogueReadCapturePath '/tmp/a.svg') 'svg is captured' +Assert-True (-not (Test-RogueReadCapturePath '/tmp/a.png')) 'png is not captured' +Assert-True (-not (Test-RogueReadCapturePath '/tmp/a.txt')) 'txt is not captured' +Assert-True (-not (Test-RogueReadCapturePath '/tmp/noext')) 'a file with no extension is not captured' +Assert-True (-not (Test-RogueReadCapturePath '/tmp/a.pdf.gz')) 'only the LAST extension counts' + +# The truncatable subset. Over the cap, only these are cut short; every other +# allowlisted type attaches nothing at all. +Assert-True (Test-RogueReadCaptureTruncatable '/tmp/a.svg') 'svg is truncatable' +Assert-True (Test-RogueReadCaptureTruncatable '/tmp/A.SVG') 'the truncatable test is case-insensitive' +Assert-True (-not (Test-RogueReadCaptureTruncatable '/tmp/a.pdf')) 'pdf is not truncatable' +Assert-True (-not (Test-RogueReadCaptureTruncatable '/tmp/noext')) 'a file with no extension is not truncatable' + +$dir = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), + 'rogue-cursor-read-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $dir -Force | Out-Null + +$pdf = [System.IO.Path]::Combine($dir, 'spec.pdf') +[System.IO.File]::WriteAllBytes($pdf, [byte[]](0x25,0x50,0x44,0x46,0x2D,0x31,0x2E,0x34)) +$expected = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($pdf)) + +$esc = $pdf.Replace('\', '\\') +$body = '{"content":"","file_path":"' + $esc + '"}' +# The WHOLE body is asserted, because a filter that dropped `content` or +# `file_path` while still appending would pass a field-only assertion. +$expectedBody = '{"content":"","file_path":"' + $esc + '","rogueFileReadB64":"' + $expected + '"}' +Assert-Eq (Add-FileReadBytes $body) $expectedBody 'the field is appended and the rest of the body survives' + +$busy = '{"content":"already here","file_path":"' + $esc + '"}' +Assert-Eq (Add-FileReadBytes $busy) $busy 'non-empty content leaves the body untouched' + +$png = [System.IO.Path]::Combine($dir, 'i.png') +[System.IO.File]::WriteAllBytes($png, [byte[]](1,2,3)) +$pngBody = '{"content":"","file_path":"' + $png.Replace('\', '\\') + '"}' +Assert-Eq (Add-FileReadBytes $pngBody) $pngBody 'an extension outside the allowlist leaves the body untouched' + +$missing = '{"content":"","file_path":"' + ([System.IO.Path]::Combine($dir, 'nope.pdf')).Replace('\', '\\') + '"}' +Assert-Eq (Add-FileReadBytes $missing) $missing 'a missing file leaves the body untouched' + +$emptyFile = [System.IO.Path]::Combine($dir, 'empty.pdf') +[System.IO.File]::WriteAllBytes($emptyFile, [byte[]]@()) +$emptyBody = '{"content":"","file_path":"' + $emptyFile.Replace('\', '\\') + '"}' +Assert-Eq (Add-FileReadBytes $emptyBody) $emptyBody 'a zero-byte file leaves the body untouched' + +$rel = '{"content":"","file_path":"relative/x.pdf"}' +Assert-Eq (Add-FileReadBytes $rel) $rel 'a relative path leaves the body untouched' + +# --- jq path == concat path ----------------------------------------------- +# Only one of the two runs on a given machine. Both GitHub runner images ship +# jq and a typical Windows Cursor box has none, so emptying PATH is the only +# way to cover the concat half here. +function Invoke-WithoutJq { + # NOT named $Body: `& $Action` resolves the scriptblock's free variables + # against THIS scope first, so that name would shadow the caller's $body and + # the scriptblock would pass itself. + param([scriptblock]$Action) + $rogueSavedPath = $env:PATH + try { $env:PATH = ''; & $Action } finally { $env:PATH = $rogueSavedPath } +} +Assert-Null (Invoke-WithoutJq { Get-Command jq -ErrorAction SilentlyContinue }) 'emptying PATH really does hide jq' + +$concat = Invoke-WithoutJq { Add-FileReadBytes $body } +Assert-Eq $concat $expectedBody 'concat path emits the documented bytes' + +# Exactly ONE closing brace is stripped: a TrimEnd would eat both and corrupt a +# body whose last value is a nested object. +$nested = '{"content":"","file_path":"' + $esc + '","meta":{"a":1}}' +$nestedExpected = '{"content":"","file_path":"' + $esc + '","meta":{"a":1},"rogueFileReadB64":"' + $expected + '"}' +$nestedConcat = Invoke-WithoutJq { Add-FileReadBytes $nested } +Assert-Eq $nestedConcat $nestedExpected 'concat path keeps a nested object at the end of the body' + +$trailing = $body + "`n " +Assert-Eq (Invoke-WithoutJq { Add-FileReadBytes $trailing }) $expectedBody 'concat path trims trailing whitespace before the brace strip' + +Assert-Eq (Invoke-WithoutJq { Add-FileReadBytes 'not json at all' }) 'not json at all' 'concat path leaves a body with no closing brace alone' + +if (Get-Command jq -ErrorAction SilentlyContinue) { + Assert-Eq (Add-FileReadBytes $body) $concat 'jq and concat agree byte for byte' + Assert-Eq (Add-FileReadBytes $nested) $nestedConcat 'jq and concat agree on a nested-object body' +} else { + Write-Host ' skip: jq not installed, jq path not exercised' +} + +# --- Over the cap --------------------------------------------------------- +$bytes = New-Object byte[] ($RogueFileReadMaxBytes + 10) +for ($i = 0; $i -lt $bytes.Length; $i++) { $bytes[$i] = 0x61 } + +$big = [System.IO.Path]::Combine($dir, 'big.pdf') +[System.IO.File]::WriteAllBytes($big, $bytes) +$bigBody = '{"content":"","file_path":"' + $big.Replace('\', '\\') + '"}' +Assert-Eq (Add-FileReadBytes $bigBody) $bigBody 'an over-cap non-truncatable type attaches nothing' + +# The other half of the split: without this, the assertion above would pass +# just as well if the capture were disabled wholesale. +$bigSvg = [System.IO.Path]::Combine($dir, 'big.svg') +# Distinguishable first and last bytes. With a uniform fill, reading the LAST +# cap-worth of bytes would satisfy a length-only assertion identically. +$bytes[0] = 0x02 +$bytes[$bytes.Length - 1] = 0x03 +[System.IO.File]::WriteAllBytes($bigSvg, $bytes) +$bigSvgBody = '{"content":"","file_path":"' + $bigSvg.Replace('\', '\\') + '"}' +# A local match, not the ambient $Matches: a failed -match would leave the +# previous case's capture in place and these assertions would read that. +$bigMatch = [regex]::Match((Add-FileReadBytes $bigSvgBody), '"rogueFileReadB64":"([^"]*)"') +Assert-True $bigMatch.Success 'an over-cap truncatable type still attaches a field' +$bigDecoded = [Convert]::FromBase64String($bigMatch.Groups[1].Value) +Assert-Eq $bigDecoded.Length 1048576 'an over-cap truncatable type is cut at the cap' +Assert-Eq $bigDecoded[0] ([byte]0x02) 'the cut keeps the FIRST bytes (a prefix, not the tail)' +Assert-Eq $bigDecoded[$bigDecoded.Length - 1] ([byte]0x61) 'the file last byte is not in the prefix' + +# --- Exactly at the cap --------------------------------------------------- +# One byte of slack in the dispatcher's comparison would turn this into a skip. +$atCap = [System.IO.Path]::Combine($dir, 'atcap.pdf') +[System.IO.File]::WriteAllBytes($atCap, (New-Object byte[] 1048576)) +$atCapBody = '{"content":"","file_path":"' + $atCap.Replace('\', '\\') + '"}' +$atCapMatch = [regex]::Match((Add-FileReadBytes $atCapBody), '"rogueFileReadB64":"([^"]*)"') +Assert-True $atCapMatch.Success 'a non-truncatable type exactly AT the cap still attaches a field' +Assert-Eq ([Convert]::FromBase64String($atCapMatch.Groups[1].Value)).Length 1048576 'a file exactly AT the cap is sent whole' + +Assert-Eq $RogueFileReadMaxBytes 1048576 'cap constant is 1 MiB' + +Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue + # ── teardown ─────────────────────────────────────────────────────────────── foreach ($d in $homes) { Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue } $env:USERPROFILE = $null diff --git a/tests/test_hook_sh_cursor.sh b/tests/test_hook_sh_cursor.sh index 4376287..c3b5690 100755 --- a/tests/test_hook_sh_cursor.sh +++ b/tests/test_hook_sh_cursor.sh @@ -23,7 +23,9 @@ set -euo pipefail REPO="$(cd "$(dirname "$0")/.." && pwd)" HOOK="$REPO/plugins/cursor/scripts/hook.sh" -SH="${TEST_SH:-sh}" +# TEST_SH wins, then an exported SH, so validate.yml's two lines (SH=bash and +# TEST_SH=dash) drive two different shells rather than `sh` twice. +SH="${TEST_SH:-${SH:-sh}}" PORT=$((RANDOM % 10000 + 30000)) HEADERS_FILE="$(mktemp)" @@ -437,5 +439,202 @@ assert_header "x-rogue-agent-id" "$CHILD_J" "child id read without jq" assert_body_identical "no-jq body posted byte-identical to stdin" rm -rf "$NOJQ_DIR"; NOJQ_DIR="" +# ── File read capture (beforeReadFile) ───────────────────────────────────── +# The second body exception, and the only one on beforeReadFile: when Cursor +# sends an empty `content`, the dispatcher attaches the file's own bytes as +# `rogueFileReadB64`. Case 12's "ONE exception" claim is about preToolUse, which +# still adds nothing but the pre-image. The capture is capped at 1048576 bytes; +# over the cap a truncatable extension is cut to the cap and every other one +# attaches nothing. Every failure path leaves the body untouched. + +# One top-level field of the last POSTed body ('' when absent). +posted_field() { + posted_body | python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1],""))' "$1" +} + +# Is a top-level field PRESENT ('yes'/'no')? Absence assertions need this: +# posted_field answers '' both for an absent key and for a present-but-empty +# one, so it cannot fail against a dispatcher that attaches an empty value. +posted_has_field() { + posted_body | python3 -c 'import json,sys; print("yes" if sys.argv[1] in json.load(sys.stdin) else "no")' "$1" +} + +# $1 file path: a beforeReadFile payload whose `content` is empty. +read_payload() { + printf '{"content":"","file_path":"%s"}' "$1" +} + +# $1 path: cap + 10 bytes, with a marker in the bytes past the cap. +make_over_cap_file() { + awk 'BEGIN{while(i++<1048576)printf "a"}' > "$1" + printf 'TAILMARKER' >> "$1" +} + +# $1 field: the decoded byte count of a base64 field on the last POSTed body. +decoded_size() { + printf '%s' "$(posted_field "$1")" | base64 -d 2>/dev/null | wc -c | tr -d ' ' +} + +b64_of() { base64 < "$1" | tr -d '\r\n'; } + +HOMER="$(new_home)" +FIX="$HOMER/fixtures" +mkdir -p "$FIX" + +# ── Case 17: empty content -> the file's bytes ride as rogueFileReadB64 ──── +restart_mock '{}' +PDF_FILE="$FIX/spec.pdf" +printf '%%PDF-1.4 hello pdf bytes\n' > "$PDF_FILE" +run_hook "$HOMER" beforeReadFile "$(read_payload "$PDF_FILE")" +assert_eq "$(posted_field rogueFileReadB64)" "$(b64_of "$PDF_FILE")" \ + "beforeReadFile with an empty content attaches the pdf bytes" + +# ── Case 18: an svg read is captured too ────────────────────────────────── +restart_mock '{}' +SVG_FILE="$FIX/logo.svg" +printf 'hi\n' > "$SVG_FILE" +run_hook "$HOMER" beforeReadFile "$(read_payload "$SVG_FILE")" +assert_eq "$(posted_field rogueFileReadB64)" "$(b64_of "$SVG_FILE")" \ + "an svg read is captured" + +# ── Case 19: the extension match is case-insensitive ────────────────────── +# Nothing else here exercises the lowercasing: with only lowercase fixtures, +# deleting the dispatcher's `tr` would leave every other case green. The stem +# differs from case 17's so the two cannot alias on a case-insensitive +# filesystem. +restart_mock '{}' +UPPER_FILE="$FIX/SHOUTY.PDF" +printf '%%PDF-1.4 uppercase extension\n' > "$UPPER_FILE" +run_hook "$HOMER" beforeReadFile "$(read_payload "$UPPER_FILE")" +assert_eq "$(posted_field rogueFileReadB64)" "$(b64_of "$UPPER_FILE")" \ + "an uppercase .PDF is captured (extension match is case-insensitive)" + +# ── Case 20: a NON-empty content attaches nothing ───────────────────────── +# The extension is one the capture covers, so the content is the only thing +# that can stop it; with a skipped extension this case would pin nothing. +restart_mock '{}' +BUSY_FILE="$FIX/busy.pdf" +printf '%%PDF-1.4 already sent\n' > "$BUSY_FILE" +run_hook "$HOMER" beforeReadFile "{\"content\":\"%PDF-1.4 already sent\\n\",\"file_path\":\"$BUSY_FILE\"}" +assert_eq "$(posted_has_field rogueFileReadB64)" "no" \ + "no capture when Cursor already sent the content" + +# ── Case 21: an extension outside the list attaches nothing ─────────────── +restart_mock '{}' +PNG_FILE="$FIX/i.png" +printf 'pngbytes' > "$PNG_FILE" +run_hook "$HOMER" beforeReadFile "$(read_payload "$PNG_FILE")" +assert_eq "$(posted_has_field rogueFileReadB64)" "no" \ + "no capture for an extension outside the list" + +# ── Case 22: an over-cap .svg is truncated to exactly the cap ───────────── +restart_mock '{}' +BIG_SVG="$FIX/BIG.SVG" +make_over_cap_file "$BIG_SVG" +run_hook "$HOMER" beforeReadFile "$(read_payload "$BIG_SVG")" +assert_eq "$(decoded_size rogueFileReadB64)" "1048576" \ + "an over-cap .svg is truncated to exactly the cap" +assert_eq "$(printf '%s' "$(posted_field rogueFileReadB64)" | base64 -d 2>/dev/null | grep -c TAILMARKER || true)" "0" \ + "bytes past the cap are not sent" + +# ── Case 22b: an over-cap .pdf attaches NOTHING ─────────────────────────── +# Only a truncatable extension is cut at the cap; every other one is sent whole +# or not at all, so case 22 cannot pass by disabling the capture wholesale. +restart_mock '{}' +BIG_PDF="$FIX/big.pdf" +make_over_cap_file "$BIG_PDF" +run_hook "$HOMER" beforeReadFile "$(read_payload "$BIG_PDF")" +assert_eq "$(posted_has_field rogueFileReadB64)" "no" \ + "an over-cap .pdf attaches nothing" + +# ── Case 22c: a .pdf exactly AT the cap is still sent whole ─────────────── +# Case 17 covers a tiny file; this one sits on the boundary, where an +# off-by-one in the size comparison would show up. +restart_mock '{}' +NEAR_PDF="$FIX/near.pdf" +awk 'BEGIN{while(i++<1048576)printf "a"}' > "$NEAR_PDF" +run_hook "$HOMER" beforeReadFile "$(read_payload "$NEAR_PDF")" +assert_eq "$(decoded_size rogueFileReadB64)" "1048576" \ + "a .pdf exactly at the cap is sent whole" + +# ── Case 23: the fail-open paths leave the body untouched ───────────────── +restart_mock '{}' +run_hook "$HOMER" beforeReadFile "$(read_payload "$FIX/missing.pdf")" +assert_eq "$(posted_has_field rogueFileReadB64)" "no" "a missing file attaches nothing" +restart_mock '{}' +run_hook "$HOMER" beforeReadFile "$(read_payload 'relative/x.pdf')" +assert_eq "$(posted_has_field rogueFileReadB64)" "no" "a relative path attaches nothing" +restart_mock '{}' +EMPTY_PDF="$FIX/empty.pdf" +: > "$EMPTY_PDF" +run_hook "$HOMER" beforeReadFile "$(read_payload "$EMPTY_PDF")" +assert_eq "$(posted_has_field rogueFileReadB64)" "no" "a zero-byte file attaches nothing" + +# ── Case 24: the capture is beforeReadFile-only ─────────────────────────── +restart_mock '{}' +run_hook "$HOMER" postToolUse "{\"tool_name\":\"Read\",\"content\":\"\",\"file_path\":\"$PDF_FILE\"}" +assert_eq "$(posted_has_field rogueFileReadB64)" "no" "no capture on another event" + +# ── Case 25: the jq path and the no-jq path post identical bodies ───────── +restart_mock '{}' +run_hook "$HOMER" beforeReadFile "$(read_payload "$PDF_FILE")" +with_jq="$(posted_body)" +restart_mock '{}' +NOJQ_DIR="$(make_nojq_path)" +# The farm stocks what main's cases need; the capture also sizes the file with +# `wc -c`, and without it a no-jq run would attach nothing at all. +ln -s "$(command -v wc)" "$NOJQ_DIR/wc" 2>/dev/null || true +TEST_PATH="$NOJQ_DIR" run_hook "$HOMER" beforeReadFile "$(read_payload "$PDF_FILE")" +rm -rf "$NOJQ_DIR"; NOJQ_DIR="" +# restart_mock clears the record, so a no-jq run that posted nothing would +# otherwise leave posted_body reading the jq run and matching it against itself. +if [ -s "$HEADERS_FILE" ]; then nojq_posted="yes"; else nojq_posted="no"; fi +assert_eq "$nojq_posted" "yes" "the no-jq run posts a request of its own" +assert_eq "$with_jq" "$(posted_body)" "jq and string-concat paths produce identical bodies" + +# ── Case 26: a backslash in the path attaches nothing ───────────────────── +# A deliberate divergence from hook.ps1, which unescapes and carries on. The +# fixture exists and its extension is covered, so the backslash is the only +# thing left that can stop the capture. +restart_mock '{}' +printf '%%PDF-1.4 backslash\n' > "$FIX/we\\ird.pdf" +run_hook "$HOMER" beforeReadFile "{\"content\":\"\",\"file_path\":\"$FIX/we\\\\ird.pdf\"}" +assert_eq "$(posted_has_field rogueFileReadB64)" "no" "a backslash in the path attaches nothing" + +# ── Case 27: no API key -> {}, exit 0, and NO request at all ────────────── +# `{}` plus exit 0 proves nothing on its own: an unreachable server produces +# exactly the same two. So the mock stays UP, and the env file for this run +# carries ONLY a base URL pointing at it (no key, no actor vars). A dispatcher +# that had lost its key gate would then land on the mock, where this case can +# see it, instead of on the default host. The record snapshot is the assertion +# that separates the two; the seeding run before it is what puts a record there +# to compare against. +restart_mock '{}' +HOME27="$(new_home)" +run_hook "$HOME27" preToolUse "$(payload_for 7a7a7a7a-1111-4111-8111-7a7a7a7a7a7a)" +SNAP27="$HOME27/record.snap" +cp "$HEADERS_FILE" "$SNAP27" +HOME27B="$(new_home)" +printf 'export ROGUE_BASE_URL=http://127.0.0.1:%s\n' "$PORT" > "$HOME27B/.rogue-env" +set +e +run_hook "$HOME27B" preToolUse '{"tool_name":"Shell","tool_input":{"command":"ls"}}' +RC27=$? +set -e +assert_eq "$(cat "$OUT_FILE")" '{}' "unconfigured emits {}" +assert_eq "$RC27" "0" "unconfigured exits 0" +if cmp -s "$SNAP27" "$HEADERS_FILE"; then posted27="no"; else posted27="yes"; fi +assert_eq "$posted27" "no" "unconfigured sends NO request (the mock's record is untouched)" + +# ── Case 28: no pre-image for a recognized binary extension ─────────────── +# Case 12 pins that preToolUse MAY add the pre-image; this pins when it must +# not. Asserted with assert_body_identical, so an attached-but-empty field +# fails it too. +restart_mock '{}' +HOME28="$(new_home)" +PNG_TARGET="$HOME28/x.png" +printf 'notreallyapng' > "$PNG_TARGET" +run_hook "$HOME28" preToolUse "{\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"$PNG_TARGET\",\"content\":\"x\"}}" +assert_body_identical "no pre-image for a recognized binary extension" + echo echo "All Cursor hook.sh tests passed (SH=$SH)."