From 3624546d1350c95ea9219b9c1ae7558c99d12bcf Mon Sep 17 00:00:00 2001 From: Yuval Date: Thu, 20 Aug 2026 17:49:14 +0300 Subject: [PATCH 01/15] test(cursor): add sh dispatcher harness covering relay, fail-open and pre-image Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/validate.yml | 12 ++- tests/test_hook_sh_cursor.sh | 179 +++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 4 deletions(-) create mode 100755 tests/test_hook_sh_cursor.sh diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index df1c432..95424be 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -89,16 +89,20 @@ 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; the Cursor dispatcher end-to-end suite + # also runs here, since the runner already has the python3 mock server and + # nc it needs. dash is Ubuntu's /bin/sh, i.e. what Claude Code invokes the + # hook with, so the actor cascade and the dispatcher are both exercised + # 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 TEST_SH=dash bash tests/test_install_id_sh.sh TEST_SH=dash bash tests/test_status_skill_sh.sh bash tests/test_gitignore_bundles.sh + SH=bash bash tests/test_hook_sh_cursor.sh + TEST_SH=dash bash tests/test_hook_sh_cursor.sh - name: Command and skill snippets parse # Every fenced sh/bash and powershell block in a /rogue:setup or /rogue:status # document is RUN VERBATIM by the agent when a user invokes the command, so a diff --git a/tests/test_hook_sh_cursor.sh b/tests/test_hook_sh_cursor.sh new file mode 100755 index 0000000..5a5510b --- /dev/null +++ b/tests/test_hook_sh_cursor.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# tests/test_hook_sh_cursor.sh — end-to-end for the Cursor sh dispatcher +# (plugins/cursor/scripts/hook.sh): env file → hook.sh → mock server → stdout. +# Holds the dispatcher to the verbatim-relay + header + fail-open contract, and +# covers the two places it is NOT a pure relay: the preToolUse file pre-image +# and the beforeReadFile byte capture. +# +# Cursor runs the `sh` command on macOS/Linux; override with TEST_SH=dash to +# exercise strict POSIX and catch bashisms. +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +HOOK="$REPO/plugins/cursor/scripts/hook.sh" +SH="${TEST_SH:-sh}" + +PORT=$((RANDOM % 10000 + 30000)) +HEADERS_FILE="$(mktemp)" +ENV_FILE="$(mktemp)" +OUT_FILE="$(mktemp)" +# Optional REPLACEMENT for the dispatcher's whole PATH (see make_nojq_path). +TEST_PATH="" + +cleanup() { + [ -n "${MOCK_PID:-}" ] && kill "$MOCK_PID" 2>/dev/null || true + rm -f "$ENV_FILE" "$HEADERS_FILE" "$OUT_FILE" +} +trap cleanup EXIT + +# Rewrite $ENV_FILE with the standard four exports, so a case that blanks it to +# test the unconfigured path can restore it afterwards. +write_env_file() { + cat > "$ENV_FILE" < "$OUT_FILE" + rc=$? + set -e + LAST_HOME="$tmp_home" + # KEEP_HOME=1 preserves the run's HOME so a caller can assert on hook.log. + [ "${KEEP_HOME:-0}" = "1" ] || rm -rf "$tmp_home" + return $rc +} + +# Build a PATH that has everything the dispatcher needs EXCEPT jq, so its concat +# fallback runs. jq (on macOS 26: /usr/bin/jq) sits in the same directory as the +# rest of the toolchain, so hiding it means rebuilding PATH as a symlink farm +# rather than dropping a directory. A missing entry can't cause a false pass: the +# dispatcher would fail-open and the byte-identical assertion below would fail. +# `wc` is in the list because the dispatcher calls `wc -c` in log rotation and in +# both enrichment paths — without it every no-jq case fails for the wrong reason. +# Echoes the farm dir; the caller sets TEST_PATH and removes it afterwards. +make_nojq_path() { + local d b src + d="$(mktemp -d)" + for b in "$SH" sh dirname basename date mkdir cat sed grep tr tail head wc base64 sleep curl; do + src="$(command -v "$b" 2>/dev/null || true)" + if [ -z "$src" ]; then echo "FAIL [nojq farm]: '$b' is not on PATH" >&2; exit 1; fi + ln -s "$src" "$d/$(basename "$src")" 2>/dev/null || true + done + if PATH="$d" command -v jq >/dev/null 2>&1; then + echo "FAIL [nojq farm]: jq is still reachable" >&2; exit 1 + fi + printf '%s' "$d" +} + +# The last POSTed request body, as the raw string the mock received. +posted_body() { + python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["body"])' "$HEADERS_FILE" +} +# 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" +} + +start_mock() { + MOCK_RESPONSE="$1" MOCK_STATUS="${2:-200}" \ + python3 "$REPO/tests/mock_server.py" "$PORT" "$HEADERS_FILE" & + MOCK_PID=$! + for _ in $(seq 1 50); do + nc -z 127.0.0.1 "$PORT" 2>/dev/null && return 0 + sleep 0.1 + done + echo "mock server failed to start" >&2; exit 1 +} + +restart_mock() { + [ -n "${MOCK_PID:-}" ] && kill "$MOCK_PID" 2>/dev/null || true + wait "$MOCK_PID" 2>/dev/null || true + start_mock "$@" +} + +stop_mock() { + [ -n "${MOCK_PID:-}" ] && kill "$MOCK_PID" 2>/dev/null || true + wait "$MOCK_PID" 2>/dev/null || true + MOCK_PID="" +} + +assert_eq() { + if [ "$1" != "$2" ]; then echo "FAIL [$3]: expected <$2> but got <$1>" >&2; exit 1; fi + echo " ok: $3" +} + +assert_header() { + local key="$1" expected="$2" label="$3" actual + actual=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["headers"].get(sys.argv[2], ""))' "$HEADERS_FILE" "$key") + assert_eq "$actual" "$expected" "$label" +} + +assert_no_header() { + local key="$1" label="$2" actual + actual=$(python3 -c 'import json,sys; print(sys.argv[2] in json.load(open(sys.argv[1]))["headers"])' "$HEADERS_FILE" "$key") + assert_eq "$actual" "False" "$label" +} + +# Presence-only: the value is this machine's hostname / installed version, so the +# test can assert it is sent and non-empty but not what it says. +assert_header_present() { + local key="$1" label="$2" actual + actual=$(python3 -c 'import json,sys; print(bool(json.load(open(sys.argv[1]))["headers"].get(sys.argv[2])))' "$HEADERS_FILE" "$key") + assert_eq "$actual" "True" "$label" +} + +# ── Case 1: verbatim relay + headers ────────────────────────────────────── +start_mock '{"permission":"allow"}' +set +e; run_dispatcher preToolUse '{"tool_name":"Shell","tool_input":{"command":"ls"}}'; LAST_RC=$?; set -e +out="$(cat "$OUT_FILE")" +assert_eq "$out" '{"permission":"allow"}' "response relayed verbatim" +assert_eq "$LAST_RC" "0" "exits 0 on a normal relay" +assert_header "x-rogue-event" "preToolUse" "x-rogue-event is the verbatim Cursor event name" +assert_header "x-rogue-api-key" "test-key" "x-rogue-api-key forwarded" +assert_header "x-rogue-actor-email" "test@example.com" "x-rogue-actor-email forwarded" +assert_header "x-rogue-source" "cursor" "x-rogue-source is cursor (cursor-only header)" +assert_eq "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["path"])' "$HEADERS_FILE")" \ + "/api/v1/hooks/cursor" "posts to the cursor endpoint" +stop_mock + +# ── Case 2: fail-open with no API key ───────────────────────────────────── +: > "$ENV_FILE" +set +e; run_dispatcher preToolUse '{"tool_name":"Shell"}'; LAST_RC=$?; set -e +assert_eq "$(cat "$OUT_FILE")" '{}' "emits {} when unconfigured" +assert_eq "$LAST_RC" "0" "exits 0 when unconfigured" +write_env_file # restore + +# ── Case 3: existing pre-image behaviour (regression guard) ─────────────── +PRE_FILE="$(mktemp)"; printf 'flask==1.0.0\n' > "$PRE_FILE" +start_mock '{}' +run_dispatcher preToolUse "{\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"$PRE_FILE\",\"contents\":\"flask==2.0.0\\n\"}}" >/dev/null +assert_eq "$(posted_field rogueFilePreImageB64)" "$(printf 'flask==1.0.0\n' | base64 | tr -d '\r\n')" \ + "preToolUse Write attaches the pre-edit file as rogueFilePreImageB64" +stop_mock + +# ── Case 4: pre-image is NOT attached for a binary extension ────────────── +BIN_FILE="$(mktemp -d)/x.png"; printf 'notreallyapng' > "$BIN_FILE" +start_mock '{}' +run_dispatcher preToolUse "{\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"$BIN_FILE\",\"contents\":\"x\"}}" >/dev/null +assert_eq "$(posted_field rogueFilePreImageB64)" "" "no pre-image for a recognized binary extension" +stop_mock + +echo +echo "All cursor hook.sh tests passed (SH=$SH)." From 6c4a267b538106a84252ae0f42e4468de2355aed Mon Sep 17 00:00:00 2001 From: Yuval Date: Thu, 20 Aug 2026 18:24:20 +0300 Subject: [PATCH 02/15] test(cursor): make the unconfigured case prove no request is sent Case 2 previously blanked the env file, so a dispatcher that lost its API-key gate would resolve the built-in base URL and POST off-box while the mock's record stayed trivially untouched and the case still passed. It now writes an env file carrying only ROGUE_BASE_URL pointing at the mock, so any request a keyless dispatcher makes lands on the mock and changes the record. The harness also stops swallowing an exported SH, so TEST_SH stays authoritative and SH=bash now actually takes effect, and start_mock's readiness probe falls back to a python3 socket connect when nc is absent. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_hook_sh_cursor.sh | 43 ++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/tests/test_hook_sh_cursor.sh b/tests/test_hook_sh_cursor.sh index 5a5510b..f8caf7e 100755 --- a/tests/test_hook_sh_cursor.sh +++ b/tests/test_hook_sh_cursor.sh @@ -11,7 +11,10 @@ set -euo pipefail REPO="$(cd "$(dirname "$0")/.." && pwd)" HOOK="$REPO/plugins/cursor/scripts/hook.sh" -SH="${TEST_SH:-sh}" +# TEST_SH stays authoritative; an exported SH is honored next, so the two CI +# lines (SH=bash / TEST_SH=dash) drive two genuinely different shells rather +# than both landing on /bin/sh. +SH="${TEST_SH:-${SH:-sh}}" PORT=$((RANDOM % 10000 + 30000)) HEADERS_FILE="$(mktemp)" @@ -91,12 +94,28 @@ posted_field() { posted_body | python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1],""))' "$1" } +# Is the mock accepting connections yet? `nc -z` when nc is on PATH, otherwise a +# python3 socket connect. python3 is already a hard dependency of this file (it +# runs the mock server and every assertion helper) while nc is not guaranteed on +# every image, and a missing probe binary here would fail this suite for a reason +# that has nothing to do with the dispatcher. +port_open() { + if command -v nc >/dev/null 2>&1; then + nc -z 127.0.0.1 "$PORT" 2>/dev/null + else + python3 -c 'import socket,sys +s = socket.socket(); s.settimeout(0.5) +rc = s.connect_ex(("127.0.0.1", int(sys.argv[1]))); s.close() +sys.exit(0 if rc == 0 else 1)' "$PORT" 2>/dev/null + fi +} + start_mock() { MOCK_RESPONSE="$1" MOCK_STATUS="${2:-200}" \ python3 "$REPO/tests/mock_server.py" "$PORT" "$HEADERS_FILE" & MOCK_PID=$! for _ in $(seq 1 50); do - nc -z 127.0.0.1 "$PORT" 2>/dev/null && return 0 + port_open && return 0 sleep 0.1 done echo "mock server failed to start" >&2; exit 1 @@ -151,14 +170,30 @@ assert_header "x-rogue-actor-email" "test@example.com" "x-rogue-actor-email forw assert_header "x-rogue-source" "cursor" "x-rogue-source is cursor (cursor-only header)" assert_eq "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["path"])' "$HEADERS_FILE")" \ "/api/v1/hooks/cursor" "posts to the cursor endpoint" -stop_mock # ── Case 2: fail-open with no API key ───────────────────────────────────── -: > "$ENV_FILE" +# The env file for this case carries ONLY a base URL — no key, no actor vars — so +# the dispatcher takes the unconfigured path. Naming the mock there is what makes +# the no-request assertion below meaningful: a dispatcher that sent anything at +# all would send it to the mock, which this case can see, rather than to the +# built-in default host, which it could not. A blank env file leaves no base URL +# to resolve, so the request would go somewhere unobservable and the case would +# pass while a request was being made. +# +# The mock also stays UP through this case. `{}` + exit 0 alone is what a plain +# network failure produces too, so with nothing listening those two assertions +# could not tell a working key check from an absent one; the snapshot of the +# mock's record is what separates them. +printf 'export ROGUE_BASE_URL=http://127.0.0.1:%s\n' "$PORT" > "$ENV_FILE" +SNAP="$(mktemp)"; cp "$HEADERS_FILE" "$SNAP" set +e; run_dispatcher preToolUse '{"tool_name":"Shell"}'; LAST_RC=$?; set -e assert_eq "$(cat "$OUT_FILE")" '{}' "emits {} when unconfigured" assert_eq "$LAST_RC" "0" "exits 0 when unconfigured" +if cmp -s "$SNAP" "$HEADERS_FILE"; then posted="no"; else posted="yes"; fi +rm -f "$SNAP" +assert_eq "$posted" "no" "unconfigured sends no request (mock's record untouched)" write_env_file # restore +stop_mock # ── Case 3: existing pre-image behaviour (regression guard) ─────────────── PRE_FILE="$(mktemp)"; printf 'flask==1.0.0\n' > "$PRE_FILE" From 5dbc686203f8e4a4668b9e4ffd2f76e6de8b17b6 Mon Sep 17 00:00:00 2001 From: Yuval Date: Thu, 20 Aug 2026 18:48:42 +0300 Subject: [PATCH 03/15] feat(cursor): attach file bytes on beforeReadFile when content is empty Cursor sends beforeReadFile with an empty content for some file types, so the event describes a read without carrying what was read. When content is empty and the path is in the capture list, the dispatcher now reads the file and appends rogueFileReadB64. Cap is 1 MiB and an over-cap file is truncated rather than skipped. Every failure branch relays the body byte-identical. The harness gains a posted_has_field helper so the no-field cases assert key absence rather than an empty value, which a mutant posting an empty field would otherwise satisfy. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/cursor/scripts/hook.sh | 69 ++++++++++++++++++++ tests/test_hook_sh_cursor.sh | 111 +++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index 1bdc5ae..de13eb9 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -373,10 +373,79 @@ 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. When +# that happens the file's own bytes are attached as `rogueFileReadB64`, so the +# request carries the file rather than only its path. +# +# Unlike the pre-image, a file OVER the cap is TRUNCATED to the cap rather than +# skipped: this field is never used as a baseline to subtract, so a prefix is +# useful where a partial baseline would be actively wrong. +# +# Fail-open in every branch — a non-empty `content`, an extension outside the +# list, a relative path, a missing or unreadable file, a zero-byte file or a read +# error all leave the relayed body byte-identical. +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 +} + +augment_with_file_read() { + _body="$1" + # Only when Cursor sent no content of its own. Anything non-empty means the + # payload already carries the file and this must not fire. `jq`'s `//` treats + # "" as absent, and the fallback scan yields "" for `"content":""`, so both + # branches agree on the empty case. + _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 path 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 (see + # _json_string_field). Same deliberate divergence from hook.ps1 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 + 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)" + + # Same jq-or-string-concat duality as the pre-image: jq when it is on PATH, + # otherwise strip the trailing `}`, append, re-close. base64 contains no + # JSON-special characters, so the concat is safe. + 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 + # ── POST (fail-open) ─────────────────────────────────────────────────────── command -v curl >/dev/null 2>&1 || { dbg "curl not found -> {}"; log "outcome=fail-open reason=no-curl"; printf '{}'; exit 0 diff --git a/tests/test_hook_sh_cursor.sh b/tests/test_hook_sh_cursor.sh index f8caf7e..8bedcd9 100755 --- a/tests/test_hook_sh_cursor.sh +++ b/tests/test_hook_sh_cursor.sh @@ -93,6 +93,14 @@ posted_body() { 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 in the last POSTed body ('yes'/'no')? Absence +# assertions need this rather than posted_field, which answers '' both for a key +# that is absent and for a key whose value is the empty string - so an assertion +# written with it cannot fail against a dispatcher that attaches an empty value, +# which is precisely the over-firing it is meant to catch. +posted_has_field() { + posted_body | python3 -c 'import json,sys; print("yes" if sys.argv[1] in json.load(sys.stdin) else "no")' "$1" +} # Is the mock accepting connections yet? `nc -z` when nc is on PATH, otherwise a # python3 socket connect. python3 is already a hard dependency of this file (it @@ -210,5 +218,108 @@ run_dispatcher preToolUse "{\"tool_name\":\"Write\",\"tool_input\":{\"file_path\ assert_eq "$(posted_field rogueFilePreImageB64)" "" "no pre-image for a recognized binary extension" stop_mock +# ── Case 5: beforeReadFile with empty content attaches the file bytes ───── +PDF_DIR="$(mktemp -d)"; PDF_FILE="$PDF_DIR/spec.pdf" +printf '%%PDF-1.4 hello pdf bytes\n' > "$PDF_FILE" +start_mock '{}' +run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PDF_FILE\",\"attachments\":[]}" >/dev/null +assert_eq "$(posted_field rogueFileReadB64)" "$(base64 < "$PDF_FILE" | tr -d '\r\n')" \ + "beforeReadFile with empty content attaches the pdf bytes" +stop_mock + +# ── Case 6: an svg is captured too ──────────────────────────────────────── +SVG_FILE="$PDF_DIR/logo.svg"; printf 'hi\n' > "$SVG_FILE" +start_mock '{}' +run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$SVG_FILE\"}" >/dev/null +assert_eq "$(posted_field rogueFileReadB64)" "$(base64 < "$SVG_FILE" | tr -d '\r\n')" \ + "an svg read is captured" +stop_mock + +# ── Case 7: NON-empty content is left alone ────────────────────────────── +# The fixture's extension is deliberately one the capture DOES cover: with an +# extension it skips, the case would pass whether or not the content check exists, +# so it would pin nothing. This way the non-empty content is the only thing that +# can stop the capture, which is exactly the property being asserted. +BUSY_FILE="$PDF_DIR/busy.pdf"; printf '%%PDF-1.4 already sent\n' > "$BUSY_FILE" +start_mock '{}' +run_dispatcher beforeReadFile "{\"content\":\"%PDF-1.4 already sent\\n\",\"file_path\":\"$BUSY_FILE\"}" >/dev/null +assert_eq "$(posted_has_field rogueFileReadB64)" "no" \ + "no capture when Cursor already sent content" +stop_mock + +# ── Case 8: an extension outside the allowlist is left alone ───────────── +PNG_FILE="$PDF_DIR/i.png"; printf 'pngbytes' > "$PNG_FILE" +start_mock '{}' +run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PNG_FILE\"}" >/dev/null +assert_eq "$(posted_has_field rogueFileReadB64)" "no" "no capture for an extension outside the allowlist" +stop_mock + +# ── Case 9: over-cap file is TRUNCATED to the cap, not skipped ─────────── +BIG_FILE="$PDF_DIR/big.pdf" +# 1 MiB of 'a' plus a tail that must NOT survive. +awk 'BEGIN{while(i++<1048576)printf "a"}' > "$BIG_FILE" +printf 'TAILMARKER' >> "$BIG_FILE" +start_mock '{}' +run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$BIG_FILE\"}" >/dev/null +got="$(posted_field rogueFileReadB64)" +assert_eq "$(printf '%s' "$got" | base64 -d 2>/dev/null | wc -c | tr -d ' ')" "1048576" \ + "over-cap file is truncated to exactly the cap" +assert_eq "$(printf '%s' "$got" | base64 -d 2>/dev/null | grep -c TAILMARKER || true)" "0" \ + "bytes past the cap are not sent" +stop_mock + +# ── Case 10: fail-open cases leave the body untouched ──────────────────── +start_mock '{}' +run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PDF_DIR/missing.pdf\"}" >/dev/null +assert_eq "$(posted_has_field rogueFileReadB64)" "no" "a missing file attaches nothing" +stop_mock +start_mock '{}' +run_dispatcher beforeReadFile '{"content":"","file_path":"relative/x.pdf"}' >/dev/null +assert_eq "$(posted_has_field rogueFileReadB64)" "no" "a relative path attaches nothing" +stop_mock +start_mock '{}' +EMPTY_PDF="$PDF_DIR/empty.pdf"; : > "$EMPTY_PDF" +run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$EMPTY_PDF\"}" >/dev/null +assert_eq "$(posted_has_field rogueFileReadB64)" "no" "a zero-byte file attaches nothing" +stop_mock + +# ── Case 11: capture does not fire on other events ────────────────────── +start_mock '{}' +run_dispatcher postToolUse "{\"tool_name\":\"Read\",\"content\":\"\",\"file_path\":\"$PDF_FILE\"}" >/dev/null +assert_eq "$(posted_has_field rogueFileReadB64)" "no" "capture is beforeReadFile-only" +stop_mock + +# ── Case 12: jq path and no-jq path produce byte-identical bodies ──────── +start_mock '{}' +run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PDF_FILE\"}" >/dev/null +with_jq="$(posted_body)" +stop_mock +start_mock '{}' +NOJQ_DIR="$(make_nojq_path)" +TEST_PATH="$NOJQ_DIR" +run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PDF_FILE\"}" >/dev/null +without_jq="$(posted_body)" +TEST_PATH="" +rm -rf "$NOJQ_DIR" +stop_mock +# The payload is compact, so jq's reserialization is a no-op and the two bodies +# must match byte for byte. Only ONE of these paths ever runs on a given machine, +# which is exactly why they have to be pinned to each other here. +assert_eq "$with_jq" "$without_jq" "jq and string-concat paths produce identical bodies" + + +# ── Case 13: a backslash in the path attaches nothing ──────────────────── +# Pins a DELIBERATE divergence from hook.ps1: this dispatcher bails on any path +# containing a backslash because its no-jq fallback scan does not unescape the +# JSON value, while the PowerShell side does unescape and carries on. The fixture +# file really EXISTS and its extension is in the list, so the backslash is the +# only thing that can stop the capture - without that, the missing-file check +# would answer for it and the case would pin nothing. +BSLASH_FILE="$PDF_DIR/we\\ird.pdf"; printf '%%PDF-1.4 backslash\n' > "$BSLASH_FILE" +start_mock '{}' +run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PDF_DIR/we\\\\ird.pdf\"}" >/dev/null +assert_eq "$(posted_has_field rogueFileReadB64)" "no" "a backslash in the path attaches nothing" +stop_mock + echo echo "All cursor hook.sh tests passed (SH=$SH)." From 7f4a17f818b102ff81229647acec2da3a42b3be0 Mon Sep 17 00:00:00 2001 From: Yuval Date: Thu, 20 Aug 2026 20:57:52 +0300 Subject: [PATCH 04/15] test(cursor): stop Case 12 passing on a stale record, and clean up fixtures The mock rewrites the headers file only when a request lands, so a no-jq run that posted nothing would leave the jq run's own record in place and the byte-identical comparison would match it against itself. The file is now truncated first and the case asserts the no-jq run posted at all. cleanup also removes the fixture temp dirs; Case 9's over-cap file alone is 1 MiB, so leaking it cost megabytes a run. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_hook_sh_cursor.sh | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/test_hook_sh_cursor.sh b/tests/test_hook_sh_cursor.sh index 8bedcd9..5565e13 100755 --- a/tests/test_hook_sh_cursor.sh +++ b/tests/test_hook_sh_cursor.sh @@ -26,6 +26,12 @@ TEST_PATH="" cleanup() { [ -n "${MOCK_PID:-}" ] && kill "$MOCK_PID" 2>/dev/null || true rm -f "$ENV_FILE" "$HEADERS_FILE" "$OUT_FILE" + # Fixture temp dirs/files are created as the cases run, so each needs a :- guard + # for an exit that happens before its case. $PDF_DIR is the one that matters: + # Case 9's over-cap file alone is 1 MiB, so leaking it costs megabytes a run. + [ -n "${PDF_DIR:-}" ] && rm -rf "$PDF_DIR" || true + [ -n "${BIN_DIR:-}" ] && rm -rf "$BIN_DIR" || true + [ -n "${PRE_FILE:-}" ] && rm -f "$PRE_FILE" || true } trap cleanup EXIT @@ -66,8 +72,10 @@ run_dispatcher() { # Build a PATH that has everything the dispatcher needs EXCEPT jq, so its concat # fallback runs. jq (on macOS 26: /usr/bin/jq) sits in the same directory as the # rest of the toolchain, so hiding it means rebuilding PATH as a symlink farm -# rather than dropping a directory. A missing entry can't cause a false pass: the -# dispatcher would fail-open and the byte-identical assertion below would fail. +# rather than dropping a directory. A missing entry can't cause a false pass, for +# two reasons that don't depend on how the dispatcher reacts to it: the farm build +# below aborts the suite outright if a listed binary is not on PATH, and Case 12 +# asserts the no-jq run posted a request of its own before comparing bodies. # `wc` is in the list because the dispatcher calls `wc -c` in log rotation and in # both enrichment paths — without it every no-jq case fails for the wrong reason. # Echoes the farm dir; the caller sets TEST_PATH and removes it afterwards. @@ -212,7 +220,7 @@ assert_eq "$(posted_field rogueFilePreImageB64)" "$(printf 'flask==1.0.0\n' | ba stop_mock # ── Case 4: pre-image is NOT attached for a binary extension ────────────── -BIN_FILE="$(mktemp -d)/x.png"; printf 'notreallyapng' > "$BIN_FILE" +BIN_DIR="$(mktemp -d)"; BIN_FILE="$BIN_DIR/x.png"; printf 'notreallyapng' > "$BIN_FILE" start_mock '{}' run_dispatcher preToolUse "{\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"$BIN_FILE\",\"contents\":\"x\"}}" >/dev/null assert_eq "$(posted_field rogueFilePreImageB64)" "" "no pre-image for a recognized binary extension" @@ -297,17 +305,23 @@ stop_mock start_mock '{}' NOJQ_DIR="$(make_nojq_path)" TEST_PATH="$NOJQ_DIR" +# The mock rewrites $HEADERS_FILE only when a request actually lands, and nothing +# else truncates it, so a no-jq run that posted NOTHING would leave the jq run's +# own record in place and the comparison below would match that record against +# itself. Truncating first, plus the guard, turns that into a named failure. +: > "$HEADERS_FILE" run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PDF_FILE\"}" >/dev/null -without_jq="$(posted_body)" TEST_PATH="" rm -rf "$NOJQ_DIR" stop_mock +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" +without_jq="$(posted_body)" # The payload is compact, so jq's reserialization is a no-op and the two bodies # must match byte for byte. Only ONE of these paths ever runs on a given machine, # which is exactly why they have to be pinned to each other here. assert_eq "$with_jq" "$without_jq" "jq and string-concat paths produce identical bodies" - # ── Case 13: a backslash in the path attaches nothing ──────────────────── # Pins a DELIBERATE divergence from hook.ps1: this dispatcher bails on any path # containing a backslash because its no-jq fallback scan does not unescape the From ebdd622754d5e8a59621925e4dd7078865b377cb Mon Sep 17 00:00:00 2001 From: Yuval Date: Tue, 1 Sep 2026 16:55:47 +0300 Subject: [PATCH 05/15] feat(cursor): PowerShell lockstep for beforeReadFile byte capture Mirrors the sh dispatcher: when beforeReadFile arrives with an empty content and the path is in the capture list, the file's bytes are attached as rogueFileReadB64, capped at 1 MiB with an over-cap file truncated rather than skipped. Every failure path returns the body unchanged. The file is opened with FileShare ReadWrite like the pre-image, since a file being read is likely still open in the editor, and the short-read slice is cast to byte[] explicitly for Windows PowerShell 5.1. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/validate.yml | 3 ++ plugins/cursor/scripts/hook.ps1 | 87 +++++++++++++++++++++++++++++++ tests/test_hook_ps1_cursor.ps1 | 90 +++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 tests/test_hook_ps1_cursor.ps1 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 95424be..d075ba5 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -302,6 +302,7 @@ jobs: set -euo pipefail pwsh -NoProfile -File tests/test_hook_ps1.ps1 pwsh -NoProfile -File tests/test_hook_ps1_copilot.ps1 + pwsh -NoProfile -File tests/test_hook_ps1_cursor.ps1 pwsh -NoProfile -File tests/test_hook_ps1_antigravity.ps1 pwsh -NoProfile -File tests/test_hook_logs.ps1 pwsh -NoProfile -File tests/test_ship_logs.ps1 @@ -346,6 +347,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_logs.ps1 diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index 3ddfd9c..c81cf62 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -422,6 +422,88 @@ function Add-FilePreImage { } } +# ── File read capture (beforeReadFile only) — lockstep with hook.sh ──────── +# Cursor sends `beforeReadFile` with an EMPTY `content` for some file types. When +# that happens the file's own bytes are attached as `rogueFileReadB64`, so the +# request carries the file rather than only its path. A file over the cap is +# TRUNCATED to the cap rather than skipped. 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() +} + +function Add-FileReadBytes { + # Deliberately NOT ConvertTo-Json on the whole payload, for the same reason + # as Add-FilePreImage: a full parse and reserialize could alter the vendor's + # JSON, and its default -Depth truncates. + param([string]$Body) + try { + $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 path would resolve against the hook's cwd. + # Looser than Add-FilePreImage's Windows-shaped test on purpose, and NOT a + # lockstep slip: an over-matching path here just falls through to the + # Test-Path check below and attaches nothing, whereas over-matching in the + # pre-image would report a real file as absent. Do not "align" the two. + 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 } + $take = [int][Math]::Min([int64]$len, [int64]$RogueFileReadMaxBytes) + if ($len -gt $RogueFileReadMaxBytes) { + Dbg "read capture $len B -> truncating to $RogueFileReadMaxBytes" + } + # Streamed rather than ReadAllBytes so an over-cap file is never fully + # loaded just to throw most of it away. + $buf = New-Object byte[] $take + $read = 0 + # FileShare ReadWrite, as in Add-FilePreImage: the editor may still hold + # the file open. It applies with more force here, because this fires on a + # READ - the file is very likely open at that moment, 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 } + # Cast back to byte[]: a PowerShell range index yields Object[], and + # ToBase64String takes byte[]. Windows PowerShell 5.1 is the shipping + # runtime for this file, so do not rely on its overload coercion. + 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)" + + $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 + } +} + # Test seam: dot-sourcing with ROGUE_PS_LIB_ONLY=1 loads the functions above # (e.g. ConvertFrom-ShellQuoted, Rotate-Log) without running the dispatcher. # Production never sets this, so the hook always runs its main body. @@ -578,6 +660,11 @@ $payload = Repair-DoubleEncodedUtf8 $payload # byte-identical. if ($EventName -eq 'preToolUse') { $payload = Add-FilePreImage $payload } +# File read capture (see Add-FileReadBytes) — the other append-only enrichment. +# Same rule: it only ever appends a field, and a failure leaves the body +# byte-identical. +if ($EventName -eq 'beforeReadFile') { $payload = Add-FileReadBytes $payload } + # ── POST (fail-open) ─────────────────────────────────────────────────────── $headers = @{ 'x-rogue-api-key' = $apiKey diff --git a/tests/test_hook_ps1_cursor.ps1 b/tests/test_hook_ps1_cursor.ps1 new file mode 100644 index 0000000..eb94fb6 --- /dev/null +++ b/tests/test_hook_ps1_cursor.ps1 @@ -0,0 +1,90 @@ +#!/usr/bin/env pwsh +# tests/test_hook_ps1_cursor.ps1 — unit tests for the Cursor PowerShell +# dispatcher's file-read capture helpers (plugins/cursor/scripts/hook.ps1). +# +# Lockstep partner of tests/test_hook_sh_cursor.sh: the two dispatchers must +# agree on the extension allowlist, the 1 MiB cap, the truncate-rather-than-skip +# rule and every fail-open branch. +# +# These are the ONLY automated checks that ever execute this code path on the +# Windows side: hooks.json loads hook.ps1 through a scriptblock wrapped in +# `catch { '{}' }`, so a parse or logic error there degrades silently into a +# permanent no-op for every Windows Cursor user. +# +# Run on any platform with PowerShell: pwsh tests/test_hook_ps1_cursor.ps1 +# hook.ps1 stands down on non-Windows for its MAIN body, but this test loads +# only its functions via the ROGUE_PS_LIB_ONLY seam, so it runs anywhere. + +$ErrorActionPreference = 'Stop' +$repo = Split-Path -Parent (Split-Path -Parent $PSCommandPath) +$env:ROGUE_PS_LIB_ONLY = '1' +. ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $repo 'plugins/cursor/scripts/hook.ps1')))) +$env:ROGUE_PS_LIB_ONLY = $null +# hook.ps1 sets SilentlyContinue for its own fail-open behaviour; the test +# itself wants failures to be loud. Every helper under test guards with +# try/catch, so this does not change what they do. +$ErrorActionPreference = 'Stop' + +$script:fail = 0 +function Assert-Eq { + param($Actual, $Expected, [string]$What) + if ($Actual -eq $Expected) { Write-Host "ok $What" } + else { Write-Host "FAIL $What`n expected: [$Expected]`n actual: [$Actual]"; $script:fail++ } +} + +# ── Extension allowlist ────────────────────────────────────────────────── +Assert-Eq (Test-RogueReadCapturePath '/tmp/a.pdf') $true 'pdf is captured' +Assert-Eq (Test-RogueReadCapturePath '/tmp/A.PDF') $true 'extension test is case-insensitive' +Assert-Eq (Test-RogueReadCapturePath '/tmp/a.svg') $true 'svg is captured' +Assert-Eq (Test-RogueReadCapturePath '/tmp/a.png') $false 'png is not captured' +Assert-Eq (Test-RogueReadCapturePath '/tmp/a.txt') $false 'txt is not captured' +Assert-Eq (Test-RogueReadCapturePath '/tmp/noext') $false 'a file with no extension is not captured' +Assert-Eq (Test-RogueReadCapturePath '/tmp/a.pdf.gz') $false 'only the LAST extension counts' + +# ── Add-FileReadBytes ──────────────────────────────────────────────────── +$dir = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) +New-Item -ItemType Directory -Path $dir | Out-Null +$pdf = Join-Path $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)) + +$body = '{"content":"","file_path":"' + $pdf.Replace('\','\\') + '"}' +$out = Add-FileReadBytes $body +Assert-Eq ($out -match '"rogueFileReadB64":"([^"]*)"') $true 'field is added' +Assert-Eq $Matches[1] $expected 'attached bytes are the file base64' + +$busy = '{"content":"already here","file_path":"' + $pdf.Replace('\','\\') + '"}' +Assert-Eq (Add-FileReadBytes $busy) $busy 'non-empty content leaves the body untouched' + +$png = Join-Path $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 is untouched' + +$missing = '{"content":"","file_path":"' + (Join-Path $dir 'nope.pdf').Replace('\','\\') + '"}' +Assert-Eq (Add-FileReadBytes $missing) $missing 'a missing file leaves the body untouched' + +$empty = Join-Path $dir 'empty.pdf' +[System.IO.File]::WriteAllBytes($empty, [byte[]]@()) +$emptyBody = '{"content":"","file_path":"' + $empty.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' + +# ── Truncation at the cap ──────────────────────────────────────────────── +$big = Join-Path $dir 'big.pdf' +$bytes = New-Object byte[] (1048576 + 10) +for ($i = 0; $i -lt $bytes.Length; $i++) { $bytes[$i] = 0x61 } +[System.IO.File]::WriteAllBytes($big, $bytes) +$bigBody = '{"content":"","file_path":"' + $big.Replace('\','\\') + '"}' +$bigOut = Add-FileReadBytes $bigBody +$null = $bigOut -match '"rogueFileReadB64":"([^"]*)"' +Assert-Eq ([Convert]::FromBase64String($Matches[1]).Length) 1048576 'over-cap file is truncated to the cap' + +# ── The cap constant matches hook.sh ──────────────────────────────────── +Assert-Eq $RogueFileReadMaxBytes 1048576 'cap constant is 1 MiB' + +Remove-Item -Recurse -Force $dir +if ($script:fail -gt 0) { Write-Host "`n$($script:fail) failure(s)"; exit 1 } +Write-Host "`nall assertions passed" From 9f6f4ec5e473e2a7975e01f737d4e2a64e98d98c Mon Sep 17 00:00:00 2001 From: Yuval Date: Tue, 1 Sep 2026 17:04:33 +0300 Subject: [PATCH 06/15] chore(cursor): bump to 1.2.0 and document the beforeReadFile read capture Also corrects two count claims in the Cursor section that the new enrichment falsified: the dispatcher bullet said one enrichment, and the pre-image bullet called itself the one thing added to the vendor payload. Co-Authored-By: Claude Opus 5 (1M context) --- .cursor-plugin/marketplace.json | 2 +- CLAUDE.md | 5 +++-- plugins/cursor/.cursor-plugin/plugin.json | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 5195f76..6d5993a 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -11,7 +11,7 @@ "plugins": [ { "name": "rogue-security", - "version": "1.1.2", + "version": "1.2.0", "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", "author": { "name": "Rogue Security", diff --git a/CLAUDE.md b/CLAUDE.md index e29dbcc..aba4ad8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,8 +24,9 @@ Mirrors the Claude plugin with deliberate differences: ### Cursor plugin (`plugins/cursor/`) A near-verbatim port of `qualifire-dev/rogue-plugin-cursor`'s `plugins/rogue/` (keep it in sync — re-pull on upstream changes). Mirrors the Claude/Codex dual-dispatcher with Cursor-native wiring: - **Logs like the other plugins.** It used to write nothing at all (only `ROGUE_DEBUG` stderr, which Cursor buries in its own per-session log and `/rogue:status` cannot read), so it had zero durable observability. Both dispatchers now append one line per invocation to `~/.rogue/logs/cursor.log` — same format, precedence and rotation as everyone else (see **The hook log**). -- **Dual dispatcher (sh + PowerShell), relay + ONE enrichment.** Each of the 18 Cursor events registers two `hooks.json` entries — `sh ./scripts/hook.sh ` (cwd-relative; Cursor runs hooks from the plugin root) and a PowerShell entry that loads `scripts/hook.ps1` via `$env:CURSOR_PLUGIN_ROOT`. Exactly one runs per machine (same arbitration as Claude). Endpoint `/api/v1/hooks/cursor`, header `x-rogue-source: cursor`, env var `CURSOR_PLUGIN_ROOT`. Reuses the shared `~/.rogue-env`. `setup.sh` / `setup.ps1` write it. -- **File pre-image (`preToolUse` only).** The one thing the dispatchers add to the vendor payload: on `preToolUse` with `tool_name` Write/Edit and an absolute `file_path`, they read that file (still PRE-edit at that point) and append `"rogueFilePreImageB64"`, using the same jq-or-string-concat duality and fail-open rules as Copilot's `augment_with_agent_tag`. **Every file qualifies except recognized binary extensions** (`_is_binary_path` / `Test-RogueBinaryPath`: images, fonts, archives, media, compiled artifacts, office documents, databases), whose base64 is pure payload with no text to compare; an unknown extension counts as text. Budget for it: on a file write this roughly doubles the request body, since the payload already carries the post-edit content. Cursor's `preToolUse` carries the full post-edit content and no pre-edit state, so the payload alone cannot say what the edit changed. **A missing file yields an EMPTY pre-image, and that is the create signal** — no Cursor payload field distinguishes a create from an overwrite (`old_string` is `""` for any pure insertion). **Over ~256 KB it sends NO pre-image**, never a truncated one: a partial pre-image misrepresents the file's pre-edit state instead of admitting we don't know it. Multi-hunk edits need no special handling: Cursor emits one full cycle per hunk, so the file on disk is already the correct per-hunk baseline. Field extraction prefers `jq` (it understands nesting and unescaping; `file_path` sits under `tool_input`) and falls back to a text scan only when jq is absent. +- **Dual dispatcher (sh + PowerShell), relay + TWO enrichments.** Each of the 18 Cursor events registers two `hooks.json` entries — `sh ./scripts/hook.sh ` (cwd-relative; Cursor runs hooks from the plugin root) and a PowerShell entry that loads `scripts/hook.ps1` via `$env:CURSOR_PLUGIN_ROOT`. Exactly one runs per machine (same arbitration as Claude). Endpoint `/api/v1/hooks/cursor`, header `x-rogue-source: cursor`, env var `CURSOR_PLUGIN_ROOT`. Reuses the shared `~/.rogue-env`. `setup.sh` / `setup.ps1` write it. +- **File pre-image (`preToolUse` only).** One of the two things the dispatchers add to the vendor payload: on `preToolUse` with `tool_name` Write/Edit and an absolute `file_path`, they read that file (still PRE-edit at that point) and append `"rogueFilePreImageB64"`, using the same jq-or-string-concat duality and fail-open rules as Copilot's `augment_with_agent_tag`. **Every file qualifies except recognized binary extensions** (`_is_binary_path` / `Test-RogueBinaryPath`: images, fonts, archives, media, compiled artifacts, office documents, databases), whose base64 is pure payload with no text to compare; an unknown extension counts as text. Budget for it: on a file write this roughly doubles the request body, since the payload already carries the post-edit content. Cursor's `preToolUse` carries the full post-edit content and no pre-edit state, so the payload alone cannot say what the edit changed. **A missing file yields an EMPTY pre-image, and that is the create signal** — no Cursor payload field distinguishes a create from an overwrite (`old_string` is `""` for any pure insertion). **Over ~256 KB it sends NO pre-image**, never a truncated one: a partial pre-image misrepresents the file's pre-edit state instead of admitting we don't know it. Multi-hunk edits need no special handling: Cursor emits one full cycle per hunk, so the file on disk is already the correct per-hunk baseline. Field extraction prefers `jq` (it understands nesting and unescaping; `file_path` sits under `tool_input`) and falls back to a text scan only when jq is absent. +- **File read capture (`beforeReadFile` only).** Cursor sends `beforeReadFile` with an **empty `content`** for some file types, so the event describes a read without carrying what was read. When `content` is empty and the path ends in `.pdf` or `.svg`, both dispatchers read the file and append `rogueFileReadB64` (base64, whitespace stripped), using the same `_json_string_field` extraction and jq-or-string-concat splice as the pre-image. **Cap is 1 MiB and an over-cap file is TRUNCATED, not skipped** — the opposite of the pre-image rule, and deliberately so: a pre-image is a baseline that gets subtracted, so a partial one makes everything past the cut look newly introduced, whereas this field is never subtracted from anything. Fail-open in every branch: non-empty `content`, an extension outside the two, a relative path, a backslash in the path, a missing/unreadable/zero-byte file or a read error all leave the relayed body byte-identical. Covered by `tests/test_hook_sh_cursor.sh` and `tests/test_hook_ps1_cursor.ps1` — the first dispatcher tests this plugin has had. - **Manifest is `.cursor-plugin/plugin.json`** (version is source of truth); the Cursor marketplace file is the repo-root `.cursor-plugin/marketplace.json` (source `./plugins/cursor`, plugin version must match plugin.json — enforced by `.github/workflows/validate.yml`), kept separate from `.claude-plugin/` and `.agents/plugins/`. - **No `auto-update.sh`.** The Cursor **Team Marketplace** (admin imports the repo via Dashboard) IS Cursor's native managed/auto-update path — we don't ship a script. Per-developer one-liner installs upgrade by re-running the installer. - **`commands/{setup,status}.md`**, not `skills/` — Cursor's slash-command format. diff --git a/plugins/cursor/.cursor-plugin/plugin.json b/plugins/cursor/.cursor-plugin/plugin.json index d21ba15..99a7f3a 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.2", + "version": "1.2.0", "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", "author": { "name": "rogue-security", From 32a97e70ac40ba5f987fbc33cf398b1e548f8cd8 Mon Sep 17 00:00:00 2001 From: Yuval Date: Tue, 1 Sep 2026 17:10:21 +0300 Subject: [PATCH 07/15] test(cursor): pin the concat fallback, the full body and the truncation prefix The string-concat splice is what runs on a machine without jq, which is the common case on Windows, and no assertion touched it: CI images all ship jq so only the jq branch was ever exercised. The suite now runs the branch with an emptied PATH and asserts both its documented bytes and byte-identity with the jq result. The happy path asserts the whole body rather than just the new field, and the truncation fixture has distinguishable first and last bytes so a tail read can no longer pass as a prefix. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_hook_ps1_cursor.ps1 | 72 +++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/tests/test_hook_ps1_cursor.ps1 b/tests/test_hook_ps1_cursor.ps1 index eb94fb6..50c3f10 100644 --- a/tests/test_hook_ps1_cursor.ps1 +++ b/tests/test_hook_ps1_cursor.ps1 @@ -48,10 +48,12 @@ $pdf = Join-Path $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)) -$body = '{"content":"","file_path":"' + $pdf.Replace('\','\\') + '"}' -$out = Add-FileReadBytes $body -Assert-Eq ($out -match '"rogueFileReadB64":"([^"]*)"') $true 'field is added' -Assert-Eq $Matches[1] $expected 'attached bytes are the file base64' +$esc = $pdf.Replace('\','\\') +$body = '{"content":"","file_path":"' + $esc + '"}' +# The WHOLE body, not just the new field: 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":"' + $pdf.Replace('\','\\') + '"}' Assert-Eq (Add-FileReadBytes $busy) $busy 'non-empty content leaves the body untouched' @@ -72,15 +74,73 @@ Assert-Eq (Add-FileReadBytes $emptyBody) $emptyBody 'a zero-byte file leaves the $rel = '{"content":"","file_path":"relative/x.pdf"}' Assert-Eq (Add-FileReadBytes $rel) $rel 'a relative path leaves the body untouched' +# ── jq path == concat path ───────────────────────────────────────────────── +# jq is used when it is on PATH and the string concat otherwise. Only one runs +# on a given machine, and the untested one is the one that matters most here: +# both GitHub runner images ship jq, while a typical Windows Cursor box has +# none and takes the concat path exclusively. So force it by emptying PATH, +# assert the documented bytes, then assert the two agree byte for byte. +# Lockstep with tests/test_hook_sh_cursor.sh's jq-vs-concat case. +function Invoke-WithoutJq { + # Parameter deliberately NOT named $Body: `& $sb` resolves the scriptblock's + # free variables against THIS scope first, so a $Body parameter here would + # shadow the caller's $body and the scriptblock would silently pass itself. + param([scriptblock]$Action) + $rogueSavedPath = $env:PATH + try { $env:PATH = ''; & $Action } finally { $env:PATH = $rogueSavedPath } +} +Assert-Eq (Invoke-WithoutJq { Get-Command jq -ErrorAction SilentlyContinue }) $null ` + 'emptying PATH really does hide jq' + +$concat = Invoke-WithoutJq { Add-FileReadBytes $body } +Assert-Eq $concat $expectedBody 'concat path (no jq on PATH) emits the documented bytes' + +# Exactly ONE closing brace is stripped: 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 whitespace is trimmed first so the strip lands on the real brace. +$trailing = $body + "`n " +$trailingConcat = Invoke-WithoutJq { Add-FileReadBytes $trailing } +Assert-Eq $trailingConcat $expectedBody 'concat path trims trailing whitespace before the brace strip' + +# A body the concat path cannot safely close is left alone. +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' +} +# Note: the empty-object separator branch (no comma when the body is just +# braces) is unreachable from this function - such a body carries no file_path +# and returns at the second gate. It is kept for lockstep with Add-FilePreImage +# and hook.sh, where the same branch IS reachable. + # ── Truncation at the cap ──────────────────────────────────────────────── $big = Join-Path $dir 'big.pdf' $bytes = New-Object byte[] (1048576 + 10) for ($i = 0; $i -lt $bytes.Length; $i++) { $bytes[$i] = 0x61 } +# Distinguishable ends. With a uniform fill, an implementation that read the +# LAST 1 MiB would pass a length-only assertion identically. +$bytes[0] = 0x02 +$bytes[$bytes.Length - 1] = 0x03 [System.IO.File]::WriteAllBytes($big, $bytes) $bigBody = '{"content":"","file_path":"' + $big.Replace('\','\\') + '"}' $bigOut = Add-FileReadBytes $bigBody -$null = $bigOut -match '"rogueFileReadB64":"([^"]*)"' -Assert-Eq ([Convert]::FromBase64String($Matches[1]).Length) 1048576 'over-cap file is truncated to the cap' +# A local match, not the ambient $Matches: a failed -match would otherwise +# leave the previous case's capture in place and these assertions would read it. +$bigMatch = [regex]::Match($bigOut, '"rogueFileReadB64":"([^"]*)"') +Assert-Eq $bigMatch.Success $true 'over-cap file still attaches a field' +$bigDecoded = [Convert]::FromBase64String($bigMatch.Groups[1].Value) +Assert-Eq $bigDecoded.Length 1048576 'over-cap file is truncated to the cap' +Assert-Eq $bigDecoded[0] ([byte]0x02) 'the truncation 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' # ── The cap constant matches hook.sh ──────────────────────────────────── Assert-Eq $RogueFileReadMaxBytes 1048576 'cap constant is 1 MiB' From 404a10f39825fb36012ce4aaab7bb145e322f034 Mon Sep 17 00:00:00 2001 From: Yuval Date: Tue, 1 Sep 2026 17:13:35 +0300 Subject: [PATCH 08/15] docs(cursor): correct the read-capture bullet's backslash and coverage claims The fail-open list attributed the backslash bail to both dispatchers. It is sh-only, and hook.ps1 deliberately unescapes because every Windows path arrives escaped. Read as written, the bullet said the capture can never fire on Windows, and invited the lockstep 'fix' that would make that true. Also names the PowerShell twin of the field extractor, notes the extension match is case-insensitive, and narrows the test claim to cursor-dedicated dispatcher tests, since test_hook_logs already invoked both dispatchers. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index aba4ad8..d2f7449 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ A near-verbatim port of `qualifire-dev/rogue-plugin-cursor`'s `plugins/rogue/` ( - **Logs like the other plugins.** It used to write nothing at all (only `ROGUE_DEBUG` stderr, which Cursor buries in its own per-session log and `/rogue:status` cannot read), so it had zero durable observability. Both dispatchers now append one line per invocation to `~/.rogue/logs/cursor.log` — same format, precedence and rotation as everyone else (see **The hook log**). - **Dual dispatcher (sh + PowerShell), relay + TWO enrichments.** Each of the 18 Cursor events registers two `hooks.json` entries — `sh ./scripts/hook.sh ` (cwd-relative; Cursor runs hooks from the plugin root) and a PowerShell entry that loads `scripts/hook.ps1` via `$env:CURSOR_PLUGIN_ROOT`. Exactly one runs per machine (same arbitration as Claude). Endpoint `/api/v1/hooks/cursor`, header `x-rogue-source: cursor`, env var `CURSOR_PLUGIN_ROOT`. Reuses the shared `~/.rogue-env`. `setup.sh` / `setup.ps1` write it. - **File pre-image (`preToolUse` only).** One of the two things the dispatchers add to the vendor payload: on `preToolUse` with `tool_name` Write/Edit and an absolute `file_path`, they read that file (still PRE-edit at that point) and append `"rogueFilePreImageB64"`, using the same jq-or-string-concat duality and fail-open rules as Copilot's `augment_with_agent_tag`. **Every file qualifies except recognized binary extensions** (`_is_binary_path` / `Test-RogueBinaryPath`: images, fonts, archives, media, compiled artifacts, office documents, databases), whose base64 is pure payload with no text to compare; an unknown extension counts as text. Budget for it: on a file write this roughly doubles the request body, since the payload already carries the post-edit content. Cursor's `preToolUse` carries the full post-edit content and no pre-edit state, so the payload alone cannot say what the edit changed. **A missing file yields an EMPTY pre-image, and that is the create signal** — no Cursor payload field distinguishes a create from an overwrite (`old_string` is `""` for any pure insertion). **Over ~256 KB it sends NO pre-image**, never a truncated one: a partial pre-image misrepresents the file's pre-edit state instead of admitting we don't know it. Multi-hunk edits need no special handling: Cursor emits one full cycle per hunk, so the file on disk is already the correct per-hunk baseline. Field extraction prefers `jq` (it understands nesting and unescaping; `file_path` sits under `tool_input`) and falls back to a text scan only when jq is absent. -- **File read capture (`beforeReadFile` only).** Cursor sends `beforeReadFile` with an **empty `content`** for some file types, so the event describes a read without carrying what was read. When `content` is empty and the path ends in `.pdf` or `.svg`, both dispatchers read the file and append `rogueFileReadB64` (base64, whitespace stripped), using the same `_json_string_field` extraction and jq-or-string-concat splice as the pre-image. **Cap is 1 MiB and an over-cap file is TRUNCATED, not skipped** — the opposite of the pre-image rule, and deliberately so: a pre-image is a baseline that gets subtracted, so a partial one makes everything past the cut look newly introduced, whereas this field is never subtracted from anything. Fail-open in every branch: non-empty `content`, an extension outside the two, a relative path, a backslash in the path, a missing/unreadable/zero-byte file or a read error all leave the relayed body byte-identical. Covered by `tests/test_hook_sh_cursor.sh` and `tests/test_hook_ps1_cursor.ps1` — the first dispatcher tests this plugin has had. +- **File read capture (`beforeReadFile` only).** Cursor sends `beforeReadFile` with an **empty `content`** for some file types, so the event describes a read without carrying what was read. When `content` is empty and the path ends in `.pdf` or `.svg` (case-insensitively), both dispatchers read the file and append `rogueFileReadB64` (base64, whitespace stripped), using the same `_json_string_field` / `Get-RogueJsonStringField` extraction and jq-or-string-concat splice as the pre-image. **Cap is 1 MiB and an over-cap file is TRUNCATED, not skipped** — the opposite of the pre-image rule, and deliberately so: a pre-image is a baseline that gets subtracted, so a partial one makes everything past the cut look newly introduced, whereas this field is never subtracted from anything. Fail-open in every branch: non-empty `content`, an extension outside the two, a relative path, a backslash in the path (`hook.sh` only — `hook.ps1` unescapes, because every Windows path arrives escaped), a missing/unreadable/zero-byte file or a read error all leave the relayed body byte-identical. Covered by `tests/test_hook_sh_cursor.sh` and `tests/test_hook_ps1_cursor.ps1` — the first cursor-dedicated dispatcher tests this plugin has had. - **Manifest is `.cursor-plugin/plugin.json`** (version is source of truth); the Cursor marketplace file is the repo-root `.cursor-plugin/marketplace.json` (source `./plugins/cursor`, plugin version must match plugin.json — enforced by `.github/workflows/validate.yml`), kept separate from `.claude-plugin/` and `.agents/plugins/`. - **No `auto-update.sh`.** The Cursor **Team Marketplace** (admin imports the repo via Dashboard) IS Cursor's native managed/auto-update path — we don't ship a script. Per-developer one-liner installs upgrade by re-running the installer. - **`commands/{setup,status}.md`**, not `skills/` — Cursor's slash-command format. From 1a3e3f8a099c46069e48c1e1ae3bd83ee9239ed3 Mon Sep 17 00:00:00 2001 From: Yuval Date: Tue, 1 Sep 2026 18:02:59 +0300 Subject: [PATCH 09/15] docs(cursor): correct the jq/concat splice description; test uppercase extensions Adds the sh suite's missing case-insensitivity fixture, records the Get-RogueJsonStringField .Trim() divergence in both dispatchers and CLAUDE.md, warns that plugins/cursor has diverged from upstream and must be merged rather than re-pulled, and corrects the "jq when it is on PATH" wording: the base64 goes to jq as one command-line argument, so the concat splice is the path that runs at real capture sizes. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 4 ++-- plugins/cursor/scripts/hook.ps1 | 17 +++++++++++++++++ plugins/cursor/scripts/hook.sh | 16 ++++++++++++++++ tests/test_hook_sh_cursor.sh | 33 ++++++++++++++++++++++++--------- 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9e20457..a2c7268 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,11 +22,11 @@ Mirrors the Claude plugin with deliberate differences: - **Hook trust**: Codex hashes the whole hook definition and skips untrusted command hooks until reviewed via `/hooks`. Keep `hooks.json` command strings (POSIX `command` + Windows `commandWindows`) **byte-identical forever**; mutate only `scripts/*` so trust survives updates. Setup/status commands document the one-time `/hooks` trust step. ### Cursor plugin (`plugins/cursor/`) -A near-verbatim port of `qualifire-dev/rogue-plugin-cursor`'s `plugins/rogue/` (keep it in sync — re-pull on upstream changes). Mirrors the Claude/Codex dual-dispatcher with Cursor-native wiring: +Originally a near-verbatim port of `qualifire-dev/rogue-plugin-cursor`'s `plugins/rogue/`, and still worth tracking upstream — but it has **materially diverged** and a literal re-pull would delete work that exists only here (the hook log, both dispatcher enrichments, and `tests/test_hook_{sh,ps1}_cursor.*`). **Merge upstream changes in; never copy the upstream tree over this one.** Mirrors the Claude/Codex dual-dispatcher with Cursor-native wiring: - **Logs like the other plugins.** It used to write nothing at all (only `ROGUE_DEBUG` stderr, which Cursor buries in its own per-session log and `/rogue:status` cannot read), so it had zero durable observability. Both dispatchers now append one line per invocation to `~/.rogue/logs/cursor.log` — same format, precedence and rotation as everyone else (see **The hook log**). - **Dual dispatcher (sh + PowerShell), relay + TWO enrichments.** Each of the 18 Cursor events registers two `hooks.json` entries — `sh ./scripts/hook.sh ` (cwd-relative; Cursor runs hooks from the plugin root) and a PowerShell entry that loads `scripts/hook.ps1` via `$env:CURSOR_PLUGIN_ROOT`. Exactly one runs per machine (same arbitration as Claude). Endpoint `/api/v1/hooks/cursor`, header `x-rogue-source: cursor`, env var `CURSOR_PLUGIN_ROOT`. Reuses the shared `~/.rogue-env`. `setup.sh` / `setup.ps1` write it. - **File pre-image (`preToolUse` only).** One of the two things the dispatchers add to the vendor payload: on `preToolUse` with `tool_name` Write/Edit and an absolute `file_path`, they read that file (still PRE-edit at that point) and append `"rogueFilePreImageB64"`, using the same jq-or-string-concat duality and fail-open rules as Copilot's `augment_with_agent_tag`. **Every file qualifies except recognized binary extensions** (`_is_binary_path` / `Test-RogueBinaryPath`: images, fonts, archives, media, compiled artifacts, office documents, databases), whose base64 is pure payload with no text to compare; an unknown extension counts as text. Budget for it: on a file write this roughly doubles the request body, since the payload already carries the post-edit content. Cursor's `preToolUse` carries the full post-edit content and no pre-edit state, so the payload alone cannot say what the edit changed. **A missing file yields an EMPTY pre-image, and that is the create signal** — no Cursor payload field distinguishes a create from an overwrite (`old_string` is `""` for any pure insertion). **Over ~256 KB it sends NO pre-image**, never a truncated one: a partial pre-image misrepresents the file's pre-edit state instead of admitting we don't know it. Multi-hunk edits need no special handling: Cursor emits one full cycle per hunk, so the file on disk is already the correct per-hunk baseline. Field extraction prefers `jq` (it understands nesting and unescaping; `file_path` sits under `tool_input`) and falls back to a text scan only when jq is absent. -- **File read capture (`beforeReadFile` only).** Cursor sends `beforeReadFile` with an **empty `content`** for some file types, so the event describes a read without carrying what was read. When `content` is empty and the path ends in `.pdf` or `.svg` (case-insensitively), both dispatchers read the file and append `rogueFileReadB64` (base64, whitespace stripped), using the same `_json_string_field` / `Get-RogueJsonStringField` extraction and jq-or-string-concat splice as the pre-image. **Cap is 1 MiB and an over-cap file is TRUNCATED, not skipped** — the opposite of the pre-image rule, and deliberately so: a pre-image is a baseline that gets subtracted, so a partial one makes everything past the cut look newly introduced, whereas this field is never subtracted from anything. Fail-open in every branch: non-empty `content`, an extension outside the two, a relative path, a backslash in the path (`hook.sh` only — `hook.ps1` unescapes, because every Windows path arrives escaped), a missing/unreadable/zero-byte file or a read error all leave the relayed body byte-identical. Covered by `tests/test_hook_sh_cursor.sh` and `tests/test_hook_ps1_cursor.ps1` — the first cursor-dedicated dispatcher tests this plugin has had. +- **File read capture (`beforeReadFile` only).** Cursor sends `beforeReadFile` with an **empty `content`** for some file types, so the event describes a read without carrying what was read. When `content` is empty and the path ends in `.pdf` or `.svg` (case-insensitively), both dispatchers read the file and append `rogueFileReadB64` (base64, whitespace stripped), using the same `_json_string_field` / `Get-RogueJsonStringField` extraction and jq-or-string-concat splice as the pre-image. **For this field the concat half is the one that actually runs at real capture sizes, not a rare fallback**: the base64 goes to jq as a single command-line argument, so past the platform's argv limit jq cannot be exec'd at all — measured, it fails above ~96 KiB of file on Linux (a 128 KiB per-argument cap) and above ~770 KiB on macOS (a 1 MiB total-argv cap), and Windows caps a command line at 32,767 characters (~24 KiB of file). All three sit under the 1 MiB cap, so the failed exec falls through to the concat, which emits byte-identical output (pinned by both suites) — don't "simplify" it away as dead code. **Cap is 1 MiB and an over-cap file is TRUNCATED, not skipped** — the opposite of the pre-image rule, and deliberately so: a pre-image is a baseline that gets subtracted, so a partial one makes everything past the cut look newly introduced, whereas this field is never subtracted from anything. Fail-open in every branch: non-empty `content`, an extension outside the two, a relative path, a backslash in the path (`hook.sh` only — `hook.ps1` unescapes, because every Windows path arrives escaped), a missing/unreadable/zero-byte file or a read error all leave the relayed body byte-identical. **A second sh-vs-ps divergence lives in the shared extraction and is documented, not fixed**: `Get-RogueJsonStringField` ends its jq branch with `.Trim()` while `_json_string_field` preserves interior whitespace, so a whitespace-only `"content":" "` reads as EMPTY on the PowerShell side (capture fires) and NON-EMPTY on the sh side (it does not). That is pre-existing helper behaviour shared with the pre-image; the `content` gate is simply the first place it changes an outcome, which is why the fix is a note in both dispatchers rather than a change to either helper. Covered by `tests/test_hook_sh_cursor.sh` and `tests/test_hook_ps1_cursor.ps1` — the first cursor-dedicated dispatcher tests this plugin has had. - **Manifest is `.cursor-plugin/plugin.json`** (version is source of truth); the Cursor marketplace file is the repo-root `.cursor-plugin/marketplace.json` (source `./plugins/cursor`, plugin version must match plugin.json — enforced by `.github/workflows/validate.yml`), kept separate from `.claude-plugin/` and `.agents/plugins/`. - **No `auto-update.sh`.** The Cursor **Team Marketplace** (admin imports the repo via Dashboard) IS Cursor's native managed/auto-update path — we don't ship a script. Per-developer one-liner installs upgrade by re-running the installer. - **`commands/{setup,status}.md`**, not `skills/` — Cursor's slash-command format. diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index c81cf62..5a7c01a 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -444,6 +444,13 @@ function Add-FileReadBytes { # JSON, and its default -Depth truncates. param([string]$Body) try { + # NOT in lockstep with hook.sh for a WHITESPACE-ONLY content: + # Get-RogueJsonStringField ends its jq branch with .Trim() and the sh + # side's _json_string_field does not, so "content":" " reads as empty + # here (the capture fires) and as non-empty there (it does not). + # Pre-existing helper behaviour on both sides; this gate is the first + # place it changes an outcome. Documented rather than fixed - changing + # either helper moves the pre-image's gates too. $content = Get-RogueJsonStringField $Body '.content' 'content' if ($content) { return $Body } @@ -489,6 +496,16 @@ function Add-FileReadBytes { if (-not $b64) { return $Body } Dbg "read capture attached for $fp ($($b64.Length) b64 chars)" + # jq-or-concat, as in Add-FilePreImage - but for THIS field the concat + # half below is the one that normally runs. The base64 is passed as a + # single command-line argument, so past the platform's command-line + # limit jq cannot be launched at all: Windows caps a command line at + # 32,767 characters, i.e. roughly 24 KiB of file, well under this + # function's own 1 MiB cap (the sh sibling measures the same effect at + # about 96 KiB on Linux and 770 KiB on macOS). Invoke-RogueJq then + # yields nothing and the concat runs instead - byte-identical output + # either way, which the suites pin. Do not delete the concat as dead + # code; it is the live path for a real capture. $out = Invoke-RogueJq $Body @('-c', '--arg', 'b64', $b64, '. + {rogueFileReadB64:$b64}') if ($out -and $out.StartsWith('{') -and $out.EndsWith('}')) { return $out } diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index de13eb9..282dd31 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -401,6 +401,13 @@ augment_with_file_read() { # payload already carries the file and this must not fire. `jq`'s `//` treats # "" as absent, and the fallback scan yields "" for `"content":""`, so both # branches agree on the empty case. + # + # NOT in lockstep with hook.ps1 for a WHITESPACE-ONLY content: that side's + # Get-RogueJsonStringField ends its jq branch with .Trim() and this one does + # not, so `"content":" "` reads as empty there (the capture fires) and as + # non-empty here (it does not). Pre-existing helper behaviour on both sides; + # this gate is the first place it changes an outcome. Documented rather than + # fixed — changing either helper moves the pre-image's gates too. _rc_content="$(_json_string_field "$_body" '.content' content)" [ -z "$_rc_content" ] || { printf '%s' "$_body"; return; } @@ -426,6 +433,15 @@ augment_with_file_read() { # Same jq-or-string-concat duality as the pre-image: jq when it is on PATH, # otherwise strip the trailing `}`, append, re-close. base64 contains no # JSON-special characters, so the concat is safe. + # + # For THIS field the concat half is the one that normally runs. The base64 is + # passed as a single command-line argument, so past the platform's argv limit + # jq cannot be exec'd at all: measured here, it fails above ~96 KiB of file on + # Linux (a 128 KiB per-argument cap) and above ~770 KiB on macOS (a 1 MiB + # total-argv cap), i.e. below this function's own 1 MiB cap on both. A failed + # exec leaves `_rc_out` empty, the `case` below does not match, and the concat + # runs instead — byte-identical output either way, which the suites pin. Do + # not delete the concat as dead code; it is the live path for a real capture. 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) diff --git a/tests/test_hook_sh_cursor.sh b/tests/test_hook_sh_cursor.sh index 5565e13..fc69382 100755 --- a/tests/test_hook_sh_cursor.sh +++ b/tests/test_hook_sh_cursor.sh @@ -28,7 +28,7 @@ cleanup() { rm -f "$ENV_FILE" "$HEADERS_FILE" "$OUT_FILE" # Fixture temp dirs/files are created as the cases run, so each needs a :- guard # for an exit that happens before its case. $PDF_DIR is the one that matters: - # Case 9's over-cap file alone is 1 MiB, so leaking it costs megabytes a run. + # Case 10's over-cap file alone is 1 MiB, so leaking it costs megabytes a run. [ -n "${PDF_DIR:-}" ] && rm -rf "$PDF_DIR" || true [ -n "${BIN_DIR:-}" ] && rm -rf "$BIN_DIR" || true [ -n "${PRE_FILE:-}" ] && rm -f "$PRE_FILE" || true @@ -74,7 +74,7 @@ run_dispatcher() { # rest of the toolchain, so hiding it means rebuilding PATH as a symlink farm # rather than dropping a directory. A missing entry can't cause a false pass, for # two reasons that don't depend on how the dispatcher reacts to it: the farm build -# below aborts the suite outright if a listed binary is not on PATH, and Case 12 +# below aborts the suite outright if a listed binary is not on PATH, and Case 13 # asserts the no-jq run posted a request of its own before comparing bodies. # `wc` is in the list because the dispatcher calls `wc -c` in log rotation and in # both enrichment paths — without it every no-jq case fails for the wrong reason. @@ -243,7 +243,22 @@ assert_eq "$(posted_field rogueFileReadB64)" "$(base64 < "$SVG_FILE" | tr -d '\r "an svg read is captured" stop_mock -# ── Case 7: NON-empty content is left alone ────────────────────────────── +# ── Case 7: the extension match is case-insensitive ────────────────────── +# The dispatcher lowercases the basename before matching, and nothing else in +# this suite exercises that: with only lowercase fixtures, deleting the `tr` +# would leave every other case green. The match is a pure string test, so the +# case holds on a case-insensitive filesystem too; the name is distinct from +# Case 5's so the two fixtures cannot alias each other there. The file really +# EXISTS and its bytes are asserted, so an uppercase extension dropping out of +# the allowlist shows up as an absent field rather than a passing no-op. +UPPER_FILE="$PDF_DIR/SHOUTY.PDF"; printf '%%PDF-1.4 uppercase extension\n' > "$UPPER_FILE" +start_mock '{}' +run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$UPPER_FILE\"}" >/dev/null +assert_eq "$(posted_field rogueFileReadB64)" "$(base64 < "$UPPER_FILE" | tr -d '\r\n')" \ + "an uppercase .PDF is captured (extension match is case-insensitive)" +stop_mock + +# ── Case 8: NON-empty content is left alone ────────────────────────────── # The fixture's extension is deliberately one the capture DOES cover: with an # extension it skips, the case would pass whether or not the content check exists, # so it would pin nothing. This way the non-empty content is the only thing that @@ -255,14 +270,14 @@ assert_eq "$(posted_has_field rogueFileReadB64)" "no" \ "no capture when Cursor already sent content" stop_mock -# ── Case 8: an extension outside the allowlist is left alone ───────────── +# ── Case 9: an extension outside the allowlist is left alone ───────────── PNG_FILE="$PDF_DIR/i.png"; printf 'pngbytes' > "$PNG_FILE" start_mock '{}' run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PNG_FILE\"}" >/dev/null assert_eq "$(posted_has_field rogueFileReadB64)" "no" "no capture for an extension outside the allowlist" stop_mock -# ── Case 9: over-cap file is TRUNCATED to the cap, not skipped ─────────── +# ── Case 10: over-cap file is TRUNCATED to the cap, not skipped ────────── BIG_FILE="$PDF_DIR/big.pdf" # 1 MiB of 'a' plus a tail that must NOT survive. awk 'BEGIN{while(i++<1048576)printf "a"}' > "$BIG_FILE" @@ -276,7 +291,7 @@ assert_eq "$(printf '%s' "$got" | base64 -d 2>/dev/null | grep -c TAILMARKER || "bytes past the cap are not sent" stop_mock -# ── Case 10: fail-open cases leave the body untouched ──────────────────── +# ── Case 11: fail-open cases leave the body untouched ──────────────────── start_mock '{}' run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PDF_DIR/missing.pdf\"}" >/dev/null assert_eq "$(posted_has_field rogueFileReadB64)" "no" "a missing file attaches nothing" @@ -291,13 +306,13 @@ run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$EMPTY_PDF\"}" assert_eq "$(posted_has_field rogueFileReadB64)" "no" "a zero-byte file attaches nothing" stop_mock -# ── Case 11: capture does not fire on other events ────────────────────── +# ── Case 12: capture does not fire on other events ────────────────────── start_mock '{}' run_dispatcher postToolUse "{\"tool_name\":\"Read\",\"content\":\"\",\"file_path\":\"$PDF_FILE\"}" >/dev/null assert_eq "$(posted_has_field rogueFileReadB64)" "no" "capture is beforeReadFile-only" stop_mock -# ── Case 12: jq path and no-jq path produce byte-identical bodies ──────── +# ── Case 13: jq path and no-jq path produce byte-identical bodies ──────── start_mock '{}' run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PDF_FILE\"}" >/dev/null with_jq="$(posted_body)" @@ -322,7 +337,7 @@ without_jq="$(posted_body)" # which is exactly why they have to be pinned to each other here. assert_eq "$with_jq" "$without_jq" "jq and string-concat paths produce identical bodies" -# ── Case 13: a backslash in the path attaches nothing ──────────────────── +# ── Case 14: a backslash in the path attaches nothing ──────────────────── # Pins a DELIBERATE divergence from hook.ps1: this dispatcher bails on any path # containing a backslash because its no-jq fallback scan does not unescape the # JSON value, while the PowerShell side does unescape and carries on. The fixture From d1e70a1a00a3e81a23e5e79ce8b7175e819e4653 Mon Sep 17 00:00:00 2001 From: Yuval Date: Mon, 7 Sep 2026 12:30:55 +0300 Subject: [PATCH 10/15] chore(cursor): version the read capture as 1.1.4, not 1.2.0 Co-Authored-By: Claude Opus 5 (1M context) --- .cursor-plugin/marketplace.json | 2 +- plugins/cursor/.cursor-plugin/plugin.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 6d5993a..1f228cb 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -11,7 +11,7 @@ "plugins": [ { "name": "rogue-security", - "version": "1.2.0", + "version": "1.1.4", "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", "author": { "name": "Rogue Security", diff --git a/plugins/cursor/.cursor-plugin/plugin.json b/plugins/cursor/.cursor-plugin/plugin.json index 99a7f3a..20c1bb3 100644 --- a/plugins/cursor/.cursor-plugin/plugin.json +++ b/plugins/cursor/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "Rogue Security", - "version": "1.2.0", + "version": "1.1.4", "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", "author": { "name": "rogue-security", From 2a4e621b326d1590040fecfe10068f259debaab7 Mon Sep 17 00:00:00 2001 From: Yuval Date: Mon, 7 Sep 2026 12:38:05 +0300 Subject: [PATCH 11/15] docs(cursor): cut the read-capture comments back Removes the comments that restated the code, and the note explaining the cap policy by what happens to the field after it is sent. Trims the rest to the traps a reader cannot see from the code: the sh/ps whitespace and backslash divergences, why the concat splice is the live path, why the no-jq case empties PATH, and why the absence assertions need a presence helper. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/validate.yml | 11 ++- plugins/cursor/scripts/hook.ps1 | 72 +++++++------------- plugins/cursor/scripts/hook.sh | 53 +++++---------- tests/test_hook_ps1_cursor.ps1 | 59 +++++++--------- tests/test_hook_sh_cursor.sh | 116 +++++++++++--------------------- 5 files changed, 110 insertions(+), 201 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 287f0a5..f98a682 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -89,12 +89,11 @@ jobs: [ "$fail" = 0 ] || exit 1 - name: Shell unit tests - # Mostly the dependency-free ones; the Cursor dispatcher end-to-end suite - # also runs here, since the runner already has the python3 mock server and - # nc it needs. dash is Ubuntu's /bin/sh, i.e. what Claude Code invokes the - # hook with, so the actor cascade and the dispatcher are both exercised - # under strict POSIX. Their PowerShell twins are 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 diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index 5a7c01a..4758405 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -422,11 +422,11 @@ function Add-FilePreImage { } } -# ── File read capture (beforeReadFile only) — lockstep with hook.sh ──────── -# Cursor sends `beforeReadFile` with an EMPTY `content` for some file types. When -# that happens the file's own bytes are attached as `rogueFileReadB64`, so the -# request carries the file rather than only its path. A file over the cap is -# TRUNCATED to the cap rather than skipped. Every failure path returns the body +# ── 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 the bytes are truncated, not skipped +# (the pre-image does the opposite). Every failure path returns the body # unchanged. $RogueFileReadMaxBytes = 1048576 @@ -439,28 +439,21 @@ function Test-RogueReadCapturePath { } function Add-FileReadBytes { - # Deliberately NOT ConvertTo-Json on the whole payload, for the same reason - # as Add-FilePreImage: a full parse and reserialize could alter the vendor's - # JSON, and its default -Depth truncates. + # 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 { - # NOT in lockstep with hook.sh for a WHITESPACE-ONLY content: - # Get-RogueJsonStringField ends its jq branch with .Trim() and the sh - # side's _json_string_field does not, so "content":" " reads as empty - # here (the capture fires) and as non-empty there (it does not). - # Pre-existing helper behaviour on both sides; this gate is the first - # place it changes an outcome. Documented rather than fixed - changing - # either helper moves the pre-image's gates too. + # 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 path would resolve against the hook's cwd. - # Looser than Add-FilePreImage's Windows-shaped test on purpose, and NOT a - # lockstep slip: an over-matching path here just falls through to the - # Test-Path check below and attaches nothing, whereas over-matching in the - # pre-image would report a real file as absent. Do not "align" the two. + # 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 } @@ -471,14 +464,13 @@ function Add-FileReadBytes { if ($len -gt $RogueFileReadMaxBytes) { Dbg "read capture $len B -> truncating to $RogueFileReadMaxBytes" } - # Streamed rather than ReadAllBytes so an over-cap file is never fully - # loaded just to throw most of it away. + # 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: the editor may still hold - # the file open. It applies with more force here, because this fires on a - # READ - the file is very likely open at that moment, and the default - # share mode would throw and lose the capture. + # 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) { @@ -488,24 +480,17 @@ function Add-FileReadBytes { } } finally { $fs.Dispose() } if ($read -le 0) { return $Body } - # Cast back to byte[]: a PowerShell range index yields Object[], and - # ToBase64String takes byte[]. Windows PowerShell 5.1 is the shipping - # runtime for this file, so do not rely on its overload coercion. + # 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 - but for THIS field the concat - # half below is the one that normally runs. The base64 is passed as a - # single command-line argument, so past the platform's command-line - # limit jq cannot be launched at all: Windows caps a command line at - # 32,767 characters, i.e. roughly 24 KiB of file, well under this - # function's own 1 MiB cap (the sh sibling measures the same effect at - # about 96 KiB on Linux and 770 KiB on macOS). Invoke-RogueJq then - # yields nothing and the concat runs instead - byte-identical output - # either way, which the suites pin. Do not delete the concat as dead - # code; it is the live path for a real capture. + # 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 } @@ -672,14 +657,9 @@ $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 } - -# File read capture (see Add-FileReadBytes) — the other append-only enrichment. -# Same rule: it only ever appends a field, and a failure leaves the body -# byte-identical. if ($EventName -eq 'beforeReadFile') { $payload = Add-FileReadBytes $payload } # ── POST (fail-open) ─────────────────────────────────────────────────────── diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index 282dd31..a737ffe 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -374,17 +374,11 @@ augment_with_pre_image() { } # ── File read capture (beforeReadFile only) ──────────────────────────────── -# Cursor sends `beforeReadFile` with an EMPTY `content` for some file types. When -# that happens the file's own bytes are attached as `rogueFileReadB64`, so the -# request carries the file rather than only its path. -# -# Unlike the pre-image, a file OVER the cap is TRUNCATED to the cap rather than -# skipped: this field is never used as a baseline to subtract, so a prefix is -# useful where a partial baseline would be actively wrong. -# -# Fail-open in every branch — a non-empty `content`, an extension outside the -# list, a relative path, a missing or unreadable file, a zero-byte file or a read -# error all leave the relayed body byte-identical. +# 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 the bytes are truncated, not skipped +# (the pre-image does the opposite). Every failure path returns the body +# unchanged. READ_CAPTURE_MAX_BYTES=1048576 _is_read_capture_path() { @@ -397,25 +391,17 @@ _is_read_capture_path() { augment_with_file_read() { _body="$1" - # Only when Cursor sent no content of its own. Anything non-empty means the - # payload already carries the file and this must not fire. `jq`'s `//` treats - # "" as absent, and the fallback scan yields "" for `"content":""`, so both - # branches agree on the empty case. - # - # NOT in lockstep with hook.ps1 for a WHITESPACE-ONLY content: that side's - # Get-RogueJsonStringField ends its jq branch with .Trim() and this one does - # not, so `"content":" "` reads as empty there (the capture fires) and as - # non-empty here (it does not). Pre-existing helper behaviour on both sides; - # this gate is the first place it changes an outcome. Documented rather than - # fixed — changing either helper moves the pre-image's gates too. + # 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 path would resolve against the hook's cwd. + # 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 (see - # _json_string_field). Same deliberate divergence from hook.ps1 as the pre-image. + # 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; } @@ -430,18 +416,11 @@ augment_with_file_read() { [ -n "$_rc_b64" ] || { printf '%s' "$_body"; return; } dbg "read capture attached for $_rc_fp (${#_rc_b64} b64 chars)" - # Same jq-or-string-concat duality as the pre-image: jq when it is on PATH, - # otherwise strip the trailing `}`, append, re-close. base64 contains no - # JSON-special characters, so the concat is safe. - # - # For THIS field the concat half is the one that normally runs. The base64 is - # passed as a single command-line argument, so past the platform's argv limit - # jq cannot be exec'd at all: measured here, it fails above ~96 KiB of file on - # Linux (a 128 KiB per-argument cap) and above ~770 KiB on macOS (a 1 MiB - # total-argv cap), i.e. below this function's own 1 MiB cap on both. A failed - # exec leaves `_rc_out` empty, the `case` below does not match, and the concat - # runs instead — byte-identical output either way, which the suites pin. Do - # not delete the concat as dead code; it is the live path for a real capture. + # 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) diff --git a/tests/test_hook_ps1_cursor.ps1 b/tests/test_hook_ps1_cursor.ps1 index 50c3f10..1271040 100644 --- a/tests/test_hook_ps1_cursor.ps1 +++ b/tests/test_hook_ps1_cursor.ps1 @@ -1,28 +1,21 @@ #!/usr/bin/env pwsh -# tests/test_hook_ps1_cursor.ps1 — unit tests for the Cursor PowerShell -# dispatcher's file-read capture helpers (plugins/cursor/scripts/hook.ps1). +# Unit tests for the Cursor PowerShell dispatcher's file-read capture helpers +# (plugins/cursor/scripts/hook.ps1). Lockstep partner of +# tests/test_hook_sh_cursor.sh. # -# Lockstep partner of tests/test_hook_sh_cursor.sh: the two dispatchers must -# agree on the extension allowlist, the 1 MiB cap, the truncate-rather-than-skip -# rule and every fail-open branch. +# hooks.json loads hook.ps1 through a scriptblock wrapped in `catch { '{}' }`, +# so an error there degrades silently into a permanent no-op for every Windows +# Cursor user. This file is the only thing that catches that. # -# These are the ONLY automated checks that ever execute this code path on the -# Windows side: hooks.json loads hook.ps1 through a scriptblock wrapped in -# `catch { '{}' }`, so a parse or logic error there degrades silently into a -# permanent no-op for every Windows Cursor user. -# -# Run on any platform with PowerShell: pwsh tests/test_hook_ps1_cursor.ps1 -# hook.ps1 stands down on non-Windows for its MAIN body, but this test loads -# only its functions via the ROGUE_PS_LIB_ONLY seam, so it runs anywhere. +# The ROGUE_PS_LIB_ONLY seam loads only the functions, so this runs anywhere. $ErrorActionPreference = 'Stop' $repo = Split-Path -Parent (Split-Path -Parent $PSCommandPath) $env:ROGUE_PS_LIB_ONLY = '1' . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $repo 'plugins/cursor/scripts/hook.ps1')))) $env:ROGUE_PS_LIB_ONLY = $null -# hook.ps1 sets SilentlyContinue for its own fail-open behaviour; the test -# itself wants failures to be loud. Every helper under test guards with -# try/catch, so this does not change what they do. +# hook.ps1 sets SilentlyContinue for its own fail-open behaviour. The test wants +# failures loud, and every helper here guards with try/catch anyway. $ErrorActionPreference = 'Stop' $script:fail = 0 @@ -50,8 +43,8 @@ $expected = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($pdf)) $esc = $pdf.Replace('\','\\') $body = '{"content":"","file_path":"' + $esc + '"}' -# The WHOLE body, not just the new field: a filter that dropped `content` or -# `file_path` while still appending would pass a field-only assertion. +# The whole body, since 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' @@ -75,16 +68,13 @@ $rel = '{"content":"","file_path":"relative/x.pdf"}' Assert-Eq (Add-FileReadBytes $rel) $rel 'a relative path leaves the body untouched' # ── jq path == concat path ───────────────────────────────────────────────── -# jq is used when it is on PATH and the string concat otherwise. Only one runs -# on a given machine, and the untested one is the one that matters most here: -# both GitHub runner images ship jq, while a typical Windows Cursor box has -# none and takes the concat path exclusively. So force it by emptying PATH, -# assert the documented bytes, then assert the two agree byte for byte. -# Lockstep with tests/test_hook_sh_cursor.sh's jq-vs-concat case. +# Only one path runs on a given machine. Both GitHub runner images ship jq, +# while a typical Windows Cursor box has none and takes the concat path only, +# so emptying PATH is the only way to cover it. function Invoke-WithoutJq { - # Parameter deliberately NOT named $Body: `& $sb` resolves the scriptblock's - # free variables against THIS scope first, so a $Body parameter here would - # shadow the caller's $body and the scriptblock would silently pass itself. + # Not named $Body: `& $Action` resolves the scriptblock's free variables + # against THIS scope first, so that 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 } @@ -117,24 +107,23 @@ if (Get-Command jq -ErrorAction SilentlyContinue) { } else { Write-Host ' skip: jq not installed - jq path not exercised' } -# Note: the empty-object separator branch (no comma when the body is just -# braces) is unreachable from this function - such a body carries no file_path -# and returns at the second gate. It is kept for lockstep with Add-FilePreImage -# and hook.sh, where the same branch IS reachable. +# The empty-object separator branch is unreachable from this function: such a +# body carries no file_path and returns at the second gate. It stays for +# lockstep with Add-FilePreImage and hook.sh, where it is reachable. # ── Truncation at the cap ──────────────────────────────────────────────── $big = Join-Path $dir 'big.pdf' $bytes = New-Object byte[] (1048576 + 10) for ($i = 0; $i -lt $bytes.Length; $i++) { $bytes[$i] = 0x61 } -# Distinguishable ends. With a uniform fill, an implementation that read the -# LAST 1 MiB would pass a length-only assertion identically. +# Distinguishable ends. With a uniform fill, reading the LAST 1 MiB would pass +# a length-only assertion identically. $bytes[0] = 0x02 $bytes[$bytes.Length - 1] = 0x03 [System.IO.File]::WriteAllBytes($big, $bytes) $bigBody = '{"content":"","file_path":"' + $big.Replace('\','\\') + '"}' $bigOut = Add-FileReadBytes $bigBody -# A local match, not the ambient $Matches: a failed -match would otherwise -# leave the previous case's capture in place and these assertions would read it. +# A local match, not the ambient $Matches: a failed -match would leave the +# previous case's capture in place and these assertions would read it. $bigMatch = [regex]::Match($bigOut, '"rogueFileReadB64":"([^"]*)"') Assert-Eq $bigMatch.Success $true 'over-cap file still attaches a field' $bigDecoded = [Convert]::FromBase64String($bigMatch.Groups[1].Value) diff --git a/tests/test_hook_sh_cursor.sh b/tests/test_hook_sh_cursor.sh index fc69382..67d69c2 100755 --- a/tests/test_hook_sh_cursor.sh +++ b/tests/test_hook_sh_cursor.sh @@ -1,19 +1,14 @@ #!/usr/bin/env bash -# tests/test_hook_sh_cursor.sh — end-to-end for the Cursor sh dispatcher -# (plugins/cursor/scripts/hook.sh): env file → hook.sh → mock server → stdout. -# Holds the dispatcher to the verbatim-relay + header + fail-open contract, and -# covers the two places it is NOT a pure relay: the preToolUse file pre-image -# and the beforeReadFile byte capture. +# End-to-end for the Cursor sh dispatcher (plugins/cursor/scripts/hook.sh): +# env file, hook.sh, mock server, stdout. # -# Cursor runs the `sh` command on macOS/Linux; override with TEST_SH=dash to -# exercise strict POSIX and catch bashisms. +# Cursor runs the `sh` command on macOS/Linux. TEST_SH=dash exercises strict +# POSIX and catches bashisms. set -euo pipefail REPO="$(cd "$(dirname "$0")/.." && pwd)" HOOK="$REPO/plugins/cursor/scripts/hook.sh" -# TEST_SH stays authoritative; an exported SH is honored next, so the two CI -# lines (SH=bash / TEST_SH=dash) drive two genuinely different shells rather -# than both landing on /bin/sh. +# TEST_SH wins, then an exported SH, so the two CI lines drive two shells. SH="${TEST_SH:-${SH:-sh}}" PORT=$((RANDOM % 10000 + 30000)) @@ -26,17 +21,15 @@ TEST_PATH="" cleanup() { [ -n "${MOCK_PID:-}" ] && kill "$MOCK_PID" 2>/dev/null || true rm -f "$ENV_FILE" "$HEADERS_FILE" "$OUT_FILE" - # Fixture temp dirs/files are created as the cases run, so each needs a :- guard - # for an exit that happens before its case. $PDF_DIR is the one that matters: - # Case 10's over-cap file alone is 1 MiB, so leaking it costs megabytes a run. + # Fixtures appear as the cases run, so each needs a :- guard for an earlier + # exit. Case 10's over-cap file alone is 1 MiB. [ -n "${PDF_DIR:-}" ] && rm -rf "$PDF_DIR" || true [ -n "${BIN_DIR:-}" ] && rm -rf "$BIN_DIR" || true [ -n "${PRE_FILE:-}" ] && rm -f "$PRE_FILE" || true } trap cleanup EXIT -# Rewrite $ENV_FILE with the standard four exports, so a case that blanks it to -# test the unconfigured path can restore it afterwards. +# Case 2 blanks $ENV_FILE to test the unconfigured path, then restores it. write_env_file() { cat > "$ENV_FILE" </dev/null 2>&1; then nc -z 127.0.0.1 "$PORT" 2>/dev/null @@ -166,8 +148,7 @@ assert_no_header() { assert_eq "$actual" "False" "$label" } -# Presence-only: the value is this machine's hostname / installed version, so the -# test can assert it is sent and non-empty but not what it says. +# Presence-only: the value is this machine's hostname or installed version. assert_header_present() { local key="$1" label="$2" actual actual=$(python3 -c 'import json,sys; print(bool(json.load(open(sys.argv[1]))["headers"].get(sys.argv[2])))' "$HEADERS_FILE" "$key") @@ -188,18 +169,11 @@ assert_eq "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["pa "/api/v1/hooks/cursor" "posts to the cursor endpoint" # ── Case 2: fail-open with no API key ───────────────────────────────────── -# The env file for this case carries ONLY a base URL — no key, no actor vars — so -# the dispatcher takes the unconfigured path. Naming the mock there is what makes -# the no-request assertion below meaningful: a dispatcher that sent anything at -# all would send it to the mock, which this case can see, rather than to the -# built-in default host, which it could not. A blank env file leaves no base URL -# to resolve, so the request would go somewhere unobservable and the case would -# pass while a request was being made. -# -# The mock also stays UP through this case. `{}` + exit 0 alone is what a plain -# network failure produces too, so with nothing listening those two assertions -# could not tell a working key check from an absent one; the snapshot of the -# mock's record is what separates them. +# The env file carries ONLY a base URL, so a dispatcher that sent anything would +# send it to the mock, where this case can see it. A blank file leaves no base +# URL and the request would go somewhere unobservable. The mock also stays up: +# `{}` + exit 0 is what a network failure produces too, so only the snapshot +# separates a working key check from an absent one. printf 'export ROGUE_BASE_URL=http://127.0.0.1:%s\n' "$PORT" > "$ENV_FILE" SNAP="$(mktemp)"; cp "$HEADERS_FILE" "$SNAP" set +e; run_dispatcher preToolUse '{"tool_name":"Shell"}'; LAST_RC=$?; set -e @@ -244,13 +218,9 @@ assert_eq "$(posted_field rogueFileReadB64)" "$(base64 < "$SVG_FILE" | tr -d '\r stop_mock # ── Case 7: the extension match is case-insensitive ────────────────────── -# The dispatcher lowercases the basename before matching, and nothing else in -# this suite exercises that: with only lowercase fixtures, deleting the `tr` -# would leave every other case green. The match is a pure string test, so the -# case holds on a case-insensitive filesystem too; the name is distinct from -# Case 5's so the two fixtures cannot alias each other there. The file really -# EXISTS and its bytes are asserted, so an uppercase extension dropping out of -# the allowlist shows up as an absent field rather than a passing no-op. +# Nothing else here exercises the lowercasing: with only lowercase fixtures, +# deleting the `tr` would leave every other case green. The name differs from +# Case 5's so the two cannot alias on a case-insensitive filesystem. UPPER_FILE="$PDF_DIR/SHOUTY.PDF"; printf '%%PDF-1.4 uppercase extension\n' > "$UPPER_FILE" start_mock '{}' run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$UPPER_FILE\"}" >/dev/null @@ -259,10 +229,8 @@ assert_eq "$(posted_field rogueFileReadB64)" "$(base64 < "$UPPER_FILE" | tr -d ' stop_mock # ── Case 8: NON-empty content is left alone ────────────────────────────── -# The fixture's extension is deliberately one the capture DOES cover: with an -# extension it skips, the case would pass whether or not the content check exists, -# so it would pin nothing. This way the non-empty content is the only thing that -# can stop the capture, which is exactly the property being asserted. +# The extension is one the capture covers, so the content is the only thing +# that can stop it. With a skipped extension the case would pin nothing. BUSY_FILE="$PDF_DIR/busy.pdf"; printf '%%PDF-1.4 already sent\n' > "$BUSY_FILE" start_mock '{}' run_dispatcher beforeReadFile "{\"content\":\"%PDF-1.4 already sent\\n\",\"file_path\":\"$BUSY_FILE\"}" >/dev/null @@ -320,10 +288,8 @@ stop_mock start_mock '{}' NOJQ_DIR="$(make_nojq_path)" TEST_PATH="$NOJQ_DIR" -# The mock rewrites $HEADERS_FILE only when a request actually lands, and nothing -# else truncates it, so a no-jq run that posted NOTHING would leave the jq run's -# own record in place and the comparison below would match that record against -# itself. Truncating first, plus the guard, turns that into a named failure. +# The mock rewrites $HEADERS_FILE only when a request lands, so a no-jq run that +# posted nothing would leave the jq run's record and match it against itself. : > "$HEADERS_FILE" run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PDF_FILE\"}" >/dev/null TEST_PATH="" @@ -332,18 +298,14 @@ stop_mock 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" without_jq="$(posted_body)" -# The payload is compact, so jq's reserialization is a no-op and the two bodies -# must match byte for byte. Only ONE of these paths ever runs on a given machine, -# which is exactly why they have to be pinned to each other here. +# The payload is compact, so jq's reserialization is a no-op. Only one path ever +# runs on a given machine, which is why they are pinned to each other here. assert_eq "$with_jq" "$without_jq" "jq and string-concat paths produce identical bodies" # ── Case 14: a backslash in the path attaches nothing ──────────────────── -# Pins a DELIBERATE divergence from hook.ps1: this dispatcher bails on any path -# containing a backslash because its no-jq fallback scan does not unescape the -# JSON value, while the PowerShell side does unescape and carries on. The fixture -# file really EXISTS and its extension is in the list, so the backslash is the -# only thing that can stop the capture - without that, the missing-file check -# would answer for it and the case would pin nothing. +# A deliberate divergence from hook.ps1, which unescapes and carries on. The +# fixture exists and its extension is in the list, so the backslash is the only +# thing that can stop the capture. BSLASH_FILE="$PDF_DIR/we\\ird.pdf"; printf '%%PDF-1.4 backslash\n' > "$BSLASH_FILE" start_mock '{}' run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PDF_DIR/we\\\\ird.pdf\"}" >/dev/null From bcbf21028ed46b059b3cbcd4cfe1b27de67f8737 Mon Sep 17 00:00:00 2001 From: Yuval Date: Mon, 7 Sep 2026 17:19:25 +0300 Subject: [PATCH 12/15] fix(cursor): send an over-cap read capture whole or not at all Only an extension whose bytes stay usable when cut short is truncated at the cap. Anything else over the cap now attaches nothing, as the pre-image already does. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/cursor/scripts/hook.sh | 20 ++++++++++++++--- tests/test_hook_sh_cursor.sh | 39 +++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index a737ffe..6d3bca2 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -376,9 +376,9 @@ augment_with_pre_image() { # ── 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 the bytes are truncated, not skipped -# (the pre-image does the opposite). Every failure path returns the body -# unchanged. +# 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() { @@ -389,6 +389,16 @@ _is_read_capture_path() { 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 `//` @@ -410,6 +420,10 @@ augment_with_file_read() { 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') diff --git a/tests/test_hook_sh_cursor.sh b/tests/test_hook_sh_cursor.sh index 67d69c2..bc9cfb7 100755 --- a/tests/test_hook_sh_cursor.sh +++ b/tests/test_hook_sh_cursor.sh @@ -22,7 +22,7 @@ cleanup() { [ -n "${MOCK_PID:-}" ] && kill "$MOCK_PID" 2>/dev/null || true rm -f "$ENV_FILE" "$HEADERS_FILE" "$OUT_FILE" # Fixtures appear as the cases run, so each needs a :- guard for an earlier - # exit. Case 10's over-cap file alone is 1 MiB. + # exit. Case 10's over-cap fixtures are 1 MiB each, three of them. [ -n "${PDF_DIR:-}" ] && rm -rf "$PDF_DIR" || true [ -n "${BIN_DIR:-}" ] && rm -rf "$BIN_DIR" || true [ -n "${PRE_FILE:-}" ] && rm -f "$PRE_FILE" || true @@ -245,20 +245,43 @@ run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PNG_FILE\"}" > assert_eq "$(posted_has_field rogueFileReadB64)" "no" "no capture for an extension outside the allowlist" stop_mock -# ── Case 10: over-cap file is TRUNCATED to the cap, not skipped ────────── -BIG_FILE="$PDF_DIR/big.pdf" -# 1 MiB of 'a' plus a tail that must NOT survive. -awk 'BEGIN{while(i++<1048576)printf "a"}' > "$BIG_FILE" -printf 'TAILMARKER' >> "$BIG_FILE" +# ── Case 10: over-cap .pdf attaches nothing ────────────────────────────── +BIG_PDF="$PDF_DIR/big.pdf" +# 1 MiB of 'a' plus a tail, so the file is over the cap by a known amount. +awk 'BEGIN{while(i++<1048576)printf "a"}' > "$BIG_PDF" +printf 'TAILMARKER' >> "$BIG_PDF" start_mock '{}' -run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$BIG_FILE\"}" >/dev/null +run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$BIG_PDF\"}" >/dev/null +assert_eq "$(posted_has_field rogueFileReadB64)" "no" \ + "an over-cap .pdf attaches nothing" +stop_mock + +# ── Case 10b: over-cap .svg is still TRUNCATED to the cap ──────────────── +# The other half of the split: the cap still truncates for a truncatable +# extension, so Case 10 cannot pass by disabling the capture wholesale. +BIG_SVG="$PDF_DIR/BIG.SVG" +awk 'BEGIN{while(i++<1048576)printf "a"}' > "$BIG_SVG" +printf 'TAILMARKER' >> "$BIG_SVG" +start_mock '{}' +run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$BIG_SVG\"}" >/dev/null got="$(posted_field rogueFileReadB64)" assert_eq "$(printf '%s' "$got" | base64 -d 2>/dev/null | wc -c | tr -d ' ')" "1048576" \ - "over-cap file is truncated to exactly the cap" + "an over-cap .svg is truncated to exactly the cap" assert_eq "$(printf '%s' "$got" | base64 -d 2>/dev/null | grep -c TAILMARKER || true)" "0" \ "bytes past the cap are not sent" stop_mock +# ── Case 10c: a .pdf AT the cap is unaffected by the over-cap rule ─────── +# Case 5 already covers this with a tiny fixture; this one is exactly AT the +# cap, where an off-by-one in the comparison (`-ge` for `-gt`) would show up. +NEAR_PDF="$PDF_DIR/near.pdf" +awk 'BEGIN{while(i++<1048576)printf "a"}' > "$NEAR_PDF" +start_mock '{}' +run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$NEAR_PDF\"}" >/dev/null +assert_eq "$(printf '%s' "$(posted_field rogueFileReadB64)" | base64 -d 2>/dev/null | wc -c | tr -d ' ')" \ + "1048576" "a .pdf exactly AT the cap is still sent whole" +stop_mock + # ── Case 11: fail-open cases leave the body untouched ──────────────────── start_mock '{}' run_dispatcher beforeReadFile "{\"content\":\"\",\"file_path\":\"$PDF_DIR/missing.pdf\"}" >/dev/null From 052f6c64be9410e2aab709552d281747d85c1494 Mon Sep 17 00:00:00 2001 From: Yuval Date: Mon, 7 Sep 2026 17:19:37 +0300 Subject: [PATCH 13/15] fix(cursor): mirror the over-cap read capture skip in hook.ps1 Lockstep partner of the hook.sh change: only a truncatable extension is cut at the cap, anything else over the cap attaches nothing. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/cursor/scripts/hook.ps1 | 22 ++++++++++++++++---- tests/test_hook_ps1_cursor.ps1 | 36 +++++++++++++++++++++++++++------ 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index 4758405..c2058b5 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -425,9 +425,9 @@ 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 the bytes are truncated, not skipped -# (the pre-image does the opposite). Every failure path returns the body -# unchanged. +# 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 { @@ -438,6 +438,16 @@ function Test-RogueReadCapturePath { 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. @@ -460,10 +470,14 @@ function Add-FileReadBytes { $len = (Get-Item -LiteralPath $fp).Length if ($len -le 0) { return $Body } - $take = [int][Math]::Min([int64]$len, [int64]$RogueFileReadMaxBytes) 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 diff --git a/tests/test_hook_ps1_cursor.ps1 b/tests/test_hook_ps1_cursor.ps1 index 1271040..c5f4e16 100644 --- a/tests/test_hook_ps1_cursor.ps1 +++ b/tests/test_hook_ps1_cursor.ps1 @@ -34,6 +34,12 @@ Assert-Eq (Test-RogueReadCapturePath '/tmp/a.txt') $false 'txt is not captured' Assert-Eq (Test-RogueReadCapturePath '/tmp/noext') $false 'a file with no extension is not captured' Assert-Eq (Test-RogueReadCapturePath '/tmp/a.pdf.gz') $false 'only the LAST extension counts' +# ── Truncatable subset ─────────────────────────────────────────────────── +Assert-Eq (Test-RogueReadCaptureTruncatable '/tmp/a.svg') $true 'svg is truncatable' +Assert-Eq (Test-RogueReadCaptureTruncatable '/tmp/A.SVG') $true 'truncatable test is case-insensitive' +Assert-Eq (Test-RogueReadCaptureTruncatable '/tmp/a.pdf') $false 'pdf is not truncatable' +Assert-Eq (Test-RogueReadCaptureTruncatable '/tmp/noext') $false 'a file with no extension is not truncatable' + # ── Add-FileReadBytes ──────────────────────────────────────────────────── $dir = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) New-Item -ItemType Directory -Path $dir | Out-Null @@ -111,26 +117,44 @@ if (Get-Command jq -ErrorAction SilentlyContinue) { # body carries no file_path and returns at the second gate. It stays for # lockstep with Add-FilePreImage and hook.sh, where it is reachable. -# ── Truncation at the cap ──────────────────────────────────────────────── +# ── Over the cap: skipped for a non-truncatable type ──────────────────── $big = Join-Path $dir 'big.pdf' $bytes = New-Object byte[] (1048576 + 10) for ($i = 0; $i -lt $bytes.Length; $i++) { $bytes[$i] = 0x61 } +[System.IO.File]::WriteAllBytes($big, $bytes) +$bigBody = '{"content":"","file_path":"' + $big.Replace('\','\\') + '"}' +Assert-Eq (Add-FileReadBytes $bigBody) $bigBody 'an over-cap pdf leaves the body untouched' + +# ── Over the cap: still truncated for a truncatable type ──────────────── +# The other half of the split. Without this, the assertion above would also +# pass if the capture were disabled wholesale. +$bigSvg = Join-Path $dir 'big.svg' # Distinguishable ends. With a uniform fill, reading the LAST 1 MiB would pass # a length-only assertion identically. $bytes[0] = 0x02 $bytes[$bytes.Length - 1] = 0x03 -[System.IO.File]::WriteAllBytes($big, $bytes) -$bigBody = '{"content":"","file_path":"' + $big.Replace('\','\\') + '"}' -$bigOut = Add-FileReadBytes $bigBody +[System.IO.File]::WriteAllBytes($bigSvg, $bytes) +$bigSvgBody = '{"content":"","file_path":"' + $bigSvg.Replace('\','\\') + '"}' +$bigOut = Add-FileReadBytes $bigSvgBody # A local match, not the ambient $Matches: a failed -match would leave the # previous case's capture in place and these assertions would read it. $bigMatch = [regex]::Match($bigOut, '"rogueFileReadB64":"([^"]*)"') -Assert-Eq $bigMatch.Success $true 'over-cap file still attaches a field' +Assert-Eq $bigMatch.Success $true 'an over-cap svg still attaches a field' $bigDecoded = [Convert]::FromBase64String($bigMatch.Groups[1].Value) -Assert-Eq $bigDecoded.Length 1048576 'over-cap file is truncated to the cap' +Assert-Eq $bigDecoded.Length 1048576 'an over-cap svg is truncated to the cap' Assert-Eq $bigDecoded[0] ([byte]0x02) 'the truncation 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' +# ── At the cap: sent whole ────────────────────────────────────────────── +# One byte of slack in the comparison would turn this into a skip. +$atCap = Join-Path $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-Eq $atCapMatch.Success $true 'a pdf exactly AT the cap still attaches a field' +Assert-Eq ([Convert]::FromBase64String($atCapMatch.Groups[1].Value)).Length 1048576 ` + 'a pdf exactly AT the cap is sent whole' + # ── The cap constant matches hook.sh ──────────────────────────────────── Assert-Eq $RogueFileReadMaxBytes 1048576 'cap constant is 1 MiB' From c4a251bbce90e10c483ae956f31dde0d8972546c Mon Sep 17 00:00:00 2001 From: Yuval Date: Tue, 8 Sep 2026 11:30:24 +0300 Subject: [PATCH 14/15] fix(test): reap the spawner job in a way Windows PowerShell 5.1 accepts Receive-Job -AutoRemoveJob is valid only for custom job types. On a plain background job 5.1 takes the job-persistence path and fails the suite with "The Persistence Path does not exist." pwsh 7 tolerates it, so this only surfaced once the suite was wired into the Windows job. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_hook_ps1_cursor.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_hook_ps1_cursor.ps1 b/tests/test_hook_ps1_cursor.ps1 index 4ce57e9..b18ea1e 100644 --- a/tests/test_hook_ps1_cursor.ps1 +++ b/tests/test_hook_ps1_cursor.ps1 @@ -236,7 +236,13 @@ $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/Receive/Remove rather than `Receive-Job -Wait -AutoRemoveJob`, which is +# valid only for custom job types: on a plain background job Windows PowerShell +# 5.1 takes the job-persistence path and throws "The Persistence Path does not +# exist." pwsh 7 tolerates it, so only the 5.1 job catches this. +Wait-Job $job | Out-Null +Receive-Job $job | Out-Null +Remove-Job $job | Out-Null 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' From c9eb28c587028ca24af3cdfe85a5505d913bfc2a Mon Sep 17 00:00:00 2001 From: Yuval Date: Tue, 8 Sep 2026 11:33:27 +0300 Subject: [PATCH 15/15] fix(test): stop calling Receive-Job, which 5.1 cannot service on the runner The previous fix blamed -AutoRemoveJob; it was wrong. Plain Receive-Job throws the same "The Persistence Path does not exist." on the Windows runner, and ErrorActionPreference Stop turns that into a dead suite. Nothing needs the job's output, only its side effect, so the call goes away and teardown becomes best-effort. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_hook_ps1_cursor.ps1 | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_hook_ps1_cursor.ps1 b/tests/test_hook_ps1_cursor.ps1 index b18ea1e..2bf78a7 100644 --- a/tests/test_hook_ps1_cursor.ps1 +++ b/tests/test_hook_ps1_cursor.ps1 @@ -236,13 +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) -# Wait/Receive/Remove rather than `Receive-Job -Wait -AutoRemoveJob`, which is -# valid only for custom job types: on a plain background job Windows PowerShell -# 5.1 takes the job-persistence path and throws "The Persistence Path does not -# exist." pwsh 7 tolerates it, so only the 5.1 job catches this. +# 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 -Receive-Job $job | Out-Null -Remove-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'