diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index d94468b..1f228cb 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -11,7 +11,7 @@ "plugins": [ { "name": "rogue-security", - "version": "1.1.3", + "version": "1.1.4", "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", "author": { "name": "Rogue Security", diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 39eb8ea..3630089 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -307,6 +307,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 diff --git a/plugins/cursor/.cursor-plugin/plugin.json b/plugins/cursor/.cursor-plugin/plugin.json index ae31f37..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.1.3", + "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/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index 3ddfd9c..ca376a0 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -21,6 +21,11 @@ # Fail-open everywhere: missing API key, network error, non-200, empty body, or # non-JSON response all yield `{}` on stdout, exit 0. # +# The relayed body is byte-for-byte what Cursor sent, with the single +# `rogueFilePreImageB64` exception (see Add-FilePreImage). Subagent identity +# therefore rides in HEADERS: `x-rogue-parent-session-id` / `x-rogue-agent-id`, +# resolved from Cursor's own transcript tree (see Resolve-RogueParentSession). +# # Set ROGUE_DEBUG=1 (process/user env var) to emit diagnostics to stderr; # Cursor shows stderr in its hook log without treating it as the response. # @@ -422,6 +427,214 @@ function Add-FilePreImage { } } +# ── Subagent -> parent session attribution — lockstep with hook.sh ───────── +# A Cursor subagent's preToolUse / postToolUse / afterFileEdit / +# beforeShellExecution all arrive with conversation_id == session_id == THE +# CHILD'S OWN id, and no payload field names the parent. The one place the link +# exists is Cursor's transcript tree, where THE CHILD'S ID IS THE FILENAME: +# +# %USERPROFILE%\.cursor\projects\\agent-transcripts\\subagents\.jsonl +# +# so the parent is the grandparent directory's name. That makes this a KEY +# LOOKUP, not a search: two concurrent subagents each carry their own id and each +# find their own file. Never rank by mtime, never "pick the newest file". +# +# `transcript_path` is deliberately never read: it is JSON-null on ordinary +# parent events too, so branching on it would re-attribute main-agent traffic. +# +# Nothing here touches the payload. The resolved ids leave as headers only, so +# the relayed body stays byte-for-byte what Cursor sent. +$RogueSpawnMarkerTtlSeconds = 30 # observed subagentStart lead is 3.96-6.45s + +function Get-RogueUserHome { + if ($env:USERPROFILE) { return $env:USERPROFILE } + return $env:HOME +} +function Get-RogueCursorProjectsDir { + return [System.IO.Path]::Combine((Get-RogueUserHome), '.cursor', 'projects') +} +function Get-RogueParentCacheDir { + return [System.IO.Path]::Combine((Get-RogueUserHome), '.rogue', 'cursor-parent') +} +function Get-RogueSpawnMarkerDir { + return [System.IO.Path]::Combine((Get-RogueUserHome), '.rogue', 'cursor-spawn') +} + +# A conversation id is a uuid. Anything outside that charset is not one, and it +# would also become a path component — so reject it rather than look it up. +function Test-RogueConversationId { + param([string]$Id) + if (-not $Id) { return $false } + return ($Id -match '^[A-Za-z0-9-]+$') +} + +# `workspace_roots` is an ARRAY, so it needs its own reader rather than +# Get-RogueJsonStringField. Lockstep with hook.sh's _workspace_root. +function Get-RogueWorkspaceRoot { + param([string]$Body) + $viaJq = Invoke-RogueJq $Body @('-r', '.workspace_roots[0] // empty') + if ($viaJq) { return $viaJq.Trim() } + $m = [regex]::Match($Body, '"workspace_roots"\s*:\s*\[\s*"([^"]*)"') + if (-not $m.Success) { return '' } + return $m.Groups[1].Value.Replace('\\', '\').Replace('\/', '/') +} + +# Cursor's project-directory slug: the workspace path with the leading separator +# stripped and every "/" and "." turned into "-". +function Get-RogueWorkspaceSlug { + param([string]$Body) + $root = Get-RogueWorkspaceRoot $Body + if (-not $root) { return '' } + return ($root.TrimStart('/').Replace('/', '-').Replace('.', '-')) +} + +# $ChildId's transcript file, if Cursor has written it. Slug-scoping is an +# OPTIMIZATION, not the mechanism: slug derivation has real exceptions on disk +# (numeric slugs, `empty-window`, `.code-workspace`-derived names), so a miss +# falls back to a scan of every project dir, which returns the SAME answer +# because the filename is the key. +function Get-RogueCursorParent { + param([string]$ChildId, [string]$Slug) + try { + $projects = Get-RogueCursorProjectsDir + $roots = @() + if ($Slug) { $roots += [System.IO.Path]::Combine($projects, $Slug) } + if (Test-Path -LiteralPath $projects) { + foreach ($p in (Get-ChildItem -LiteralPath $projects -Directory -ErrorAction SilentlyContinue)) { + if ($Slug -and $p.Name -eq $Slug) { continue } # already first in line + $roots += $p.FullName + } + } + foreach ($r in $roots) { + $transcripts = [System.IO.Path]::Combine($r, 'agent-transcripts') + if (-not (Test-Path -LiteralPath $transcripts)) { continue } + foreach ($d in (Get-ChildItem -LiteralPath $transcripts -Directory -ErrorAction SilentlyContinue)) { + $f = [System.IO.Path]::Combine($d.FullName, 'subagents', ($ChildId + '.jsonl')) + if (Test-Path -LiteralPath $f -PathType Leaf) { return $d.Name } + } + } + } catch { Dbg "parent lookup failed: $($_.Exception.Message)" } + return $null +} + +# Any live marker under this workspace. The check is "is SOMETHING spawning", +# never "is MY parent spawning" — a child cannot know its parent before the +# lookup succeeds. Scoped per workspace because both sides derive the slug the +# same way from workspace_roots; unscoped only when this payload has no root. +function Test-RogueSpawnMarkerLive { + param([string]$Slug) + try { + $root = Get-RogueSpawnMarkerDir + if (-not (Test-Path -LiteralPath $root)) { return $false } + $dirs = @() + if ($Slug) { + $dirs += [System.IO.Path]::Combine($root, $Slug) + } else { + foreach ($d in (Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue)) { + $dirs += $d.FullName + } + } + $cutoff = (Get-Date).AddSeconds(-$RogueSpawnMarkerTtlSeconds) + foreach ($d in $dirs) { + if (-not (Test-Path -LiteralPath $d)) { continue } + foreach ($f in (Get-ChildItem -LiteralPath $d -File -ErrorAction SilentlyContinue)) { + if ($f.LastWriteTime -ge $cutoff) { return $true } + } + } + } catch { Dbg "marker check failed: $($_.Exception.Message)" } + return $false +} + +# subagentStart fires ON THE PARENT (conversation_id == the parent's id, verified +# on all 9 real payloads) and 3.96-6.45s BEFORE the child's subagents file +# exists. That window is exactly what the marker covers. Every step is +# best-effort: a lost marker costs a wait that would not have happened, never a +# wrong answer. +function Write-RogueSpawnMarker { + param([string]$Body) + try { + $id = Get-RogueJsonStringField $Body '.conversation_id' 'conversation_id' + if (-not (Test-RogueConversationId $id)) { return } + $slug = Get-RogueWorkspaceSlug $Body + if (-not $slug) { $slug = '_' } + $dir = [System.IO.Path]::Combine((Get-RogueSpawnMarkerDir), $slug) + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force -ErrorAction SilentlyContinue | Out-Null + } + $file = [System.IO.Path]::Combine($dir, $id) + [System.IO.File]::WriteAllText($file, '') + Dbg "spawn marker $slug/$id" + } catch { Dbg "spawn marker failed: $($_.Exception.Message)" } +} + +# Best-effort only. subagentStop carries no subagent_id and a killed subagent +# never emits one, so nothing may depend on this running; the TTL is what +# actually retires a marker. +function Remove-RogueSpawnMarker { + param([string]$Body) + try { + $id = Get-RogueJsonStringField $Body '.conversation_id' 'conversation_id' + if (-not (Test-RogueConversationId $id)) { return } + $slug = Get-RogueWorkspaceSlug $Body + if (-not $slug) { $slug = '_' } + $file = [System.IO.Path]::Combine((Get-RogueSpawnMarkerDir), $slug, $id) + if (Test-Path -LiteralPath $file) { Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue } + } catch { Dbg "spawn marker cleanup failed: $($_.Exception.Message)" } +} + +# Returns @{ Parent = ; Child = } or $null. Fail-open in +# every branch: unresolved means no headers and today's POST exactly. +function Resolve-RogueParentSession { + param([string]$Body) + try { + $id = Get-RogueJsonStringField $Body '.conversation_id' 'conversation_id' + if (-not (Test-RogueConversationId $id)) { return $null } + + # Cache, mirroring the Copilot dispatcher's submap: a subagent fires + # 18-223 hooks per spawn and Cursor REUSES a child id across re-spawns, + # so the scan runs once per subagent, ever. Only a subagent's first hook + # can miss. + $cacheDir = Get-RogueParentCacheDir + $cacheFile = [System.IO.Path]::Combine($cacheDir, $id) + if (Test-Path -LiteralPath $cacheFile -PathType Leaf) { + $cached = ([System.IO.File]::ReadAllText($cacheFile)).Trim() + if ($cached) { Dbg 'parent cache hit'; return @{ Parent = $cached; Child = $id } } + } + + $slug = Get-RogueWorkspaceSlug $Body + $parent = Get-RogueCursorParent $id $slug + if (-not $parent) { + # The child's file is born 0.811-1.627s after its first hook, and its + # creation is INDEPENDENT of hook returns (one spawn's file appeared + # 2.40s before any blocking hook fired), so this wait cannot + # self-deadlock. hooks.json allows 120s per hook, so ~3s is 2.5% of + # the budget. + # + # NEVER spin without a live marker: a brand-new TOP-LEVEL + # conversation has no directory of its own for ~9s and so looks + # exactly like an unresolved child. + $max = 30 # ~3s at 100ms/iter + if ($env:ROGUE_CURSOR_PARENT_ITERS) { $max = [int]$env:ROGUE_CURSOR_PARENT_ITERS } + if (-not (Test-RogueSpawnMarkerLive $slug)) { $max = 0 } + for ($n = 0; $n -lt $max; $n++) { + Start-Sleep -Milliseconds 100 + $parent = Get-RogueCursorParent $id $slug + if ($parent) { break } + } + } + if (-not $parent) { return $null } + + if (-not (Test-Path -LiteralPath $cacheDir)) { + New-Item -ItemType Directory -Path $cacheDir -Force -ErrorAction SilentlyContinue | Out-Null + } + try { [System.IO.File]::WriteAllText($cacheFile, $parent) } catch {} + return @{ Parent = $parent; Child = $id } + } catch { + Dbg "parent resolution failed: $($_.Exception.Message)" + return $null + } +} + # 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 +791,19 @@ $payload = Repair-DoubleEncodedUtf8 $payload # byte-identical. if ($EventName -eq 'preToolUse') { $payload = Add-FilePreImage $payload } +# Only the events a subagent actually fires resolve. sessionStart / sessionEnd / +# subagentStart / subagentStop are parent-side: they already carry the parent's +# own conversation id, so resolving would be pointless and waiting would tax +# every session start. +$attribution = $null +switch ($EventName) { + 'subagentStart' { Write-RogueSpawnMarker $payload } + 'subagentStop' { Remove-RogueSpawnMarker $payload } + 'sessionStart' { } + 'sessionEnd' { } + default { $attribution = Resolve-RogueParentSession $payload } +} + # ── POST (fail-open) ─────────────────────────────────────────────────────── $headers = @{ 'x-rogue-api-key' = $apiKey @@ -589,9 +815,18 @@ $headers = @{ 'x-rogue-version' = $pluginVersion 'x-rogue-agent' = 'cursor' } +# Added CONDITIONALLY, and always as a pair: a subagent's events carry the +# parent's session id plus the child's own conversation id, a main agent's carry +# neither. Never add a key with an empty value — the backend prefers this header +# over the body's conversation_id, so an empty one would resolve to nothing. +if ($attribution) { + $headers['x-rogue-parent-session-id'] = $attribution.Parent + $headers['x-rogue-agent-id'] = $attribution.Child +} $url = "$baseUrl/api/v1/hooks/cursor" -Dbg "POST $url actor=$actorEmail" +$parentDbg = if ($attribution) { $attribution.Parent } else { 'none' } +Dbg "POST $url actor=$actorEmail parent=$parentDbg" # Send an explicit UTF-8 byte array: Windows PowerShell 5.1's Invoke-WebRequest # re-encodes a string body (commonly to Latin-1), which corrupts non-ASCII # prompt content and can reintroduce a BOM. GetBytes() never emits a BOM. diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index 1bdc5ae..93ace94 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -24,6 +24,14 @@ # The ONE exception is the file pre-image (see `augment_with_pre_image`), which # adds a field the payload cannot express and never removes or rewrites one. # +# Subagent identity rides in HEADERS for exactly that reason. A Cursor subagent's +# own events name only themselves, so `resolve_parent_session` reads the payload +# LOCALLY, looks the child's conversation id up as a FILENAME under Cursor's own +# transcript tree, and sends the parent id plus the child id as +# `x-rogue-parent-session-id` / `x-rogue-agent-id`. The parse result never +# reaches the POST, so the relayed body stays byte-for-byte what Cursor sent and +# `rogueFilePreImageB64` remains the single body exception. +# # Fail-open everywhere: missing API key, missing curl, network error, non-200, # empty body all yield `{}` on stdout, exit 0. Cursor # must never block because Rogue infrastructure is unavailable. @@ -377,17 +385,220 @@ if [ "$event" = "preToolUse" ]; then PAYLOAD="$(augment_with_pre_image "$PAYLOAD")" fi +# ── Subagent -> parent session attribution (headers only) ────────────────── +# A Cursor subagent's preToolUse / postToolUse / afterFileEdit / +# beforeShellExecution all fire hooks and all arrive with +# conversation_id == session_id == THE CHILD'S OWN id. No payload field names the +# parent, so persisted verbatim each subagent becomes its own orphaned session. +# +# The one place the link exists is Cursor's transcript tree, where THE CHILD'S ID +# IS THE FILENAME: +# +# ~/.cursor/projects//agent-transcripts//subagents/.jsonl +# +# so the parent is basename(dirname(dirname(hit))). That makes this a KEY LOOKUP, +# not a search: two concurrent subagents each carry their own id and each find +# their own file. Never rank by mtime, never "pick the newest file" — that is the +# one change that could attribute a child to the wrong parent. +# +# `transcript_path` is deliberately never read: it is JSON-null on ordinary +# parent events too (1,238 raw entries across 25+ real sessions), so branching on +# it would re-attribute main-agent traffic. +CURSOR_PROJECTS_DIR="$HOME/.cursor/projects" +PARENT_CACHE_DIR="$HOME/.rogue/cursor-parent" +SPAWN_MARKER_DIR="$HOME/.rogue/cursor-spawn" +SPAWN_MARKER_TTL=30 # seconds; the observed subagentStart lead is 3.96-6.45s +PARENT_ID="" +CHILD_ID="" + +# `workspace_roots` is an ARRAY, so it needs its own reader rather than +# _json_string_field. jq when available; otherwise match the first string inside +# the array literal. +_workspace_root() { + if command -v jq >/dev/null 2>&1; then + if _wr=$(printf '%s' "$1" | jq -r '.workspace_roots[0] // empty' 2>/dev/null); then + [ -n "$_wr" ] && { printf '%s' "$_wr"; return; } + fi + fi + printf '%s' "$1" \ + | grep -o '"workspace_roots"[[:space:]]*:[[:space:]]*\[[[:space:]]*"[^"]*"' 2>/dev/null \ + | head -1 \ + | sed -e 's/.*"\([^"]*\)"$/\1/' +} + +# Cursor's project-directory slug: the workspace path with the leading "/" +# stripped and every "/" and "." turned into "-". +_slugify_root() { printf '%s' "${1#/}" | tr '/.' '--'; } + +# A conversation id is a uuid. Anything outside that charset is not one, and it +# would also be interpolated into a path — so reject it rather than glob with it. +_is_conversation_id() { + case "$1" in + ''|*[!A-Za-z0-9-]*) return 1 ;; + esac + return 0 +} + +# $1 = child conversation id, $2 = workspace slug (may be empty). Echoes the +# parent conversation id. Slug-scoping is an OPTIMIZATION, not the mechanism: +# slug derivation has real exceptions on disk (numeric slugs, `empty-window`, +# `.code-workspace`-derived names), so a miss falls back to a global glob that +# returns the SAME answer because the filename is the key. +_lookup_parent() { + _lp_id="$1" + if [ -n "$2" ]; then + for _lp in "$CURSOR_PROJECTS_DIR/$2"/agent-transcripts/*/subagents/"$_lp_id.jsonl"; do + [ -e "$_lp" ] || continue + basename "$(dirname "$(dirname "$_lp")")" + return 0 + done + fi + for _lp in "$CURSOR_PROJECTS_DIR"/*/agent-transcripts/*/subagents/"$_lp_id.jsonl"; do + [ -e "$_lp" ] || continue + basename "$(dirname "$(dirname "$_lp")")" + return 0 + done + return 1 +} + +# Seconds since epoch for a file's mtime. GNU stat first (it rejects -f cleanly +# on BSD, whereas BSD's -f would print a bogus value under GNU). +_mtime() { + _mt=$(stat -c %Y "$1" 2>/dev/null) || _mt=$(stat -f %m "$1" 2>/dev/null) || return 1 + case "$_mt" in ''|*[!0-9]*) return 1 ;; esac + printf '%s' "$_mt" +} + +_marker_live_in() { + [ -d "$1" ] || return 1 + _now=$(date +%s 2>/dev/null) + case "$_now" in ''|*[!0-9]*) return 1 ;; esac + for _mk in "$1"/*; do + [ -f "$_mk" ] || continue + _mkt=$(_mtime "$_mk") || continue + _age=$((_now - _mkt)) + [ "$_age" -ge 0 ] && [ "$_age" -le "$SPAWN_MARKER_TTL" ] && return 0 + done + return 1 +} + +# Any live marker under this workspace. The check is "is SOMETHING spawning", +# never "is MY parent spawning" — a child cannot know its parent before the +# lookup succeeds. Scoped per workspace because both sides derive the slug the +# same way from workspace_roots; unscoped only when this payload has no root. +_marker_live() { + if [ -n "$1" ]; then _marker_live_in "$SPAWN_MARKER_DIR/$1"; return $?; fi + for _md in "$SPAWN_MARKER_DIR"/*; do + [ -d "$_md" ] || continue + _marker_live_in "$_md" && return 0 + done + return 1 +} + +# subagentStart fires ON THE PARENT (conversation_id == the parent's id, verified +# on all 9 real payloads) and 3.96-6.45s BEFORE the child's subagents/ file +# exists. That window is exactly what the marker covers: it tells a later, +# unresolved event that waiting is worth it. Every step is best-effort — a lost +# marker costs a wait that would not have happened, never a wrong answer. +mark_spawn() { + [ -n "${HOME:-}" ] || return 0 + _ms_id=$(_json_string_field "$PAYLOAD" '.conversation_id' conversation_id) + _is_conversation_id "$_ms_id" || return 0 + _ms_slug=$(_slugify_root "$(_workspace_root "$PAYLOAD")") + [ -n "$_ms_slug" ] || _ms_slug="_" + mkdir -p "$SPAWN_MARKER_DIR/$_ms_slug" 2>/dev/null || return 0 + : > "$SPAWN_MARKER_DIR/$_ms_slug/$_ms_id" 2>/dev/null || return 0 + dbg "spawn marker $_ms_slug/$_ms_id" +} + +# Best-effort only. subagentStop carries no subagent_id and a killed subagent +# never emits one, so nothing may depend on this running; the TTL is what +# actually retires a marker. +clear_spawn() { + [ -n "${HOME:-}" ] || return 0 + _cs_id=$(_json_string_field "$PAYLOAD" '.conversation_id' conversation_id) + _is_conversation_id "$_cs_id" || return 0 + _cs_slug=$(_slugify_root "$(_workspace_root "$PAYLOAD")") + [ -n "$_cs_slug" ] || _cs_slug="_" + rm -f "$SPAWN_MARKER_DIR/$_cs_slug/$_cs_id" 2>/dev/null + return 0 +} + +# Sets PARENT_ID/CHILD_ID when this event belongs to a subagent. Fail-open in +# every branch: unresolved leaves both empty and the POST is exactly today's. +resolve_parent_session() { + [ -n "${HOME:-}" ] || return 0 + _rp_id=$(_json_string_field "$PAYLOAD" '.conversation_id' conversation_id) + _is_conversation_id "$_rp_id" || return 0 + + # Cache, mirroring the Copilot dispatcher's submap: a subagent fires 18-223 + # hooks per spawn and Cursor REUSES a child id across re-spawns, so the scan + # runs once per subagent, ever. Only a subagent's first hook can miss. + _rp_cache="$PARENT_CACHE_DIR/$_rp_id" + if [ -r "$_rp_cache" ]; then + PARENT_ID=$(cat "$_rp_cache" 2>/dev/null) + if [ -n "$PARENT_ID" ]; then + CHILD_ID="$_rp_id" + dbg "parent cache hit" + return 0 + fi + fi + + _rp_slug=$(_slugify_root "$(_workspace_root "$PAYLOAD")") + PARENT_ID=$(_lookup_parent "$_rp_id" "$_rp_slug") || PARENT_ID="" + if [ -z "$PARENT_ID" ]; then + # The child's file is born 0.811-1.627s after its first hook, and its + # creation is INDEPENDENT of hook returns (one spawn's file appeared 2.40s + # before any blocking hook fired), so this wait cannot self-deadlock. + # hooks.json allows 120s per hook, so ~3s is 2.5% of the budget. + # + # NEVER spin without a live marker: a brand-new TOP-LEVEL conversation has no + # directory of its own for ~9s and so looks exactly like an unresolved child. + # Setting the ceiling to 0 rather than branching mirrors Copilot's + # `[ -d "$COPILOT_STATE_DIR" ] || _max=0`. + _rp_n=0 + _rp_max=${ROGUE_CURSOR_PARENT_ITERS:-30} # ~3s at 0.1s/iter + _marker_live "$_rp_slug" || _rp_max=0 + while [ "$_rp_n" -lt "$_rp_max" ]; do + sleep 0.1 + PARENT_ID=$(_lookup_parent "$_rp_id" "$_rp_slug") && [ -n "$PARENT_ID" ] && break + PARENT_ID="" + _rp_n=$((_rp_n + 1)) + done + fi + + [ -n "$PARENT_ID" ] || return 0 + CHILD_ID="$_rp_id" + mkdir -p "$PARENT_CACHE_DIR" 2>/dev/null + printf '%s' "$PARENT_ID" > "$_rp_cache" 2>/dev/null + return 0 +} + +# Only the events a subagent actually fires resolve. sessionStart / sessionEnd / +# subagentStart / subagentStop are parent-side: they already carry the parent's +# own conversation id, so resolving would be pointless and waiting would tax +# every session start. +case "$event" in + subagentStart) mark_spawn ;; + subagentStop) clear_spawn ;; + sessionStart|sessionEnd) : ;; + *) resolve_parent_session ;; +esac + # ── POST (fail-open) ─────────────────────────────────────────────────────── command -v curl >/dev/null 2>&1 || { dbg "curl not found -> {}"; log "outcome=fail-open reason=no-curl"; printf '{}'; exit 0 } URL="$BASE_URL/api/v1/hooks/cursor" -dbg "POST $URL actor=$actor_email" -# -f makes curl emit nothing and exit non-zero on HTTP >= 400, giving us -# fail-open on non-200 for free. -RESP="$(printf '%s' "$PAYLOAD" | curl -fsS --max-time 10 -X POST \ - -H 'Content-Type: application/json' \ +dbg "POST $URL actor=$actor_email parent=${PARENT_ID:-none}" +# The two identity headers are appended as ARGUMENTS, never as conditional +# VALUES: to curl `-H "x-rogue-parent-session-id: "` means "send it empty" and +# `-H "x-rogue-parent-session-id:"` means "suppress this header entirely", so +# neither spelling can express "do not send it". Rebuilding the argument list +# with `set --` is the only shape that omits the header. They are always sent +# together or not at all, and never on a main-agent event. +set -- -H 'Content-Type: application/json' \ -H "x-rogue-api-key: $API_KEY" \ -H "x-rogue-event: $event" \ -H "x-rogue-actor-email: $actor_email" \ @@ -395,7 +606,14 @@ RESP="$(printf '%s' "$PAYLOAD" | curl -fsS --max-time 10 -X POST \ -H 'x-rogue-source: cursor' \ -H "x-rogue-host: $host" \ -H "x-rogue-version: $plugin_version" \ - -H 'x-rogue-agent: cursor' \ + -H 'x-rogue-agent: cursor' +if [ -n "$PARENT_ID" ] && [ -n "$CHILD_ID" ]; then + set -- "$@" -H "x-rogue-parent-session-id: $PARENT_ID" -H "x-rogue-agent-id: $CHILD_ID" +fi +# -f makes curl emit nothing and exit non-zero on HTTP >= 400, giving us +# fail-open on non-200 for free. +RESP="$(printf '%s' "$PAYLOAD" | curl -fsS --max-time 10 -X POST \ + "$@" \ --data-binary @- "$URL" 2>/dev/null)"; _rc=$? dbg "curl rc=$_rc resp_len=${#RESP}" # Always log the raw response head so a relay/decision bug is diagnosable from diff --git a/tests/test_hook_ps1_cursor.ps1 b/tests/test_hook_ps1_cursor.ps1 new file mode 100644 index 0000000..04964ac --- /dev/null +++ b/tests/test_hook_ps1_cursor.ps1 @@ -0,0 +1,259 @@ +#!/usr/bin/env pwsh +# tests/test_hook_ps1_cursor.ps1 — unit tests for the Cursor PowerShell +# dispatcher's subagent -> parent session attribution +# (plugins/cursor/scripts/hook.ps1). +# +# Lockstep partner of tests/test_hook_sh_cursor.sh: every case there has a case +# here, because the repo's rule is that hook.sh and hook.ps1 move together. What +# it cannot mirror is the POST itself — hook.ps1's main body stands down on +# non-Windows, so this file loads only its FUNCTIONS through the +# ROGUE_PS_LIB_ONLY seam. The header-emit call site is covered by the sh suite +# plus the parse gate in .github/workflows/validate.yml; the resolution logic, +# which is where a wrong answer would come from, is covered here. +# +# These are the ONLY automated checks that ever execute this code: hooks.json +# loads hook.ps1 via `[scriptblock]::Create(...)` inside a catch that swallows +# failures into `{}`, so a logic error here is a silent no-op for every Windows +# Cursor user rather than an error anyone sees. +# +# Run on any platform with PowerShell: pwsh tests/test_hook_ps1_cursor.ps1 + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +# [IO.Path]::Combine takes many segments on Windows PowerShell 5.1; multi-segment +# Join-Path is PowerShell 7+ only. +$hook = [System.IO.Path]::Combine($here, '..', 'plugins', 'cursor', 'scripts', 'hook.ps1') + +# Load hook.ps1's functions without executing the dispatcher body. +$env:ROGUE_PS_LIB_ONLY = '1' +. $hook +$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 function under test guards with try/catch, so +# this does not change what they do. +$ErrorActionPreference = 'Stop' + +$fails = 0 +$count = 0 +function Assert-Eq { + param($Got, $Expected, [string]$Label) + $script:count++ + if ([string]$Got -ceq [string]$Expected) { Write-Host " ok: $Label" } + else { Write-Host "FAIL [$Label]: got <$Got>, expected <$Expected>"; $script:fails++ } +} +function Assert-True { + param($Cond, [string]$Label) + $script:count++ + if ($Cond) { Write-Host " ok: $Label" } + else { Write-Host "FAIL [$Label]: expected true, got <$Cond>"; $script:fails++ } +} +function Assert-Null { + param($Got, [string]$Label) + $script:count++ + if ($null -eq $Got) { Write-Host " ok: $Label" } + else { Write-Host "FAIL [$Label]: expected null, got <$Got>"; $script:fails++ } +} + +# ── harness ──────────────────────────────────────────────────────────────── +# The functions read %USERPROFILE% (falling back to $HOME), so pointing it at a +# throwaway directory is what isolates a case. +$homes = @() +function New-TestHome { + $d = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), + 'rogue-cursor-ps-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $d -Force | Out-Null + $script:homes += $d + $env:USERPROFILE = $d + return $d +} +function New-ChildFile { + param([string]$Root, [string]$Slug, [string]$Parent, [string]$Child) + $dir = [System.IO.Path]::Combine($Root, '.cursor', 'projects', $Slug, 'agent-transcripts', $Parent, 'subagents') + New-Item -ItemType Directory -Path $dir -Force | Out-Null + [System.IO.File]::WriteAllText([System.IO.Path]::Combine($dir, ($Child + '.jsonl')), '') +} +function New-Marker { + param([string]$Root, [string]$Slug, [string]$Id, [int]$AgeSeconds = 0) + $dir = [System.IO.Path]::Combine($Root, '.rogue', 'cursor-spawn', $Slug) + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $f = [System.IO.Path]::Combine($dir, $Id) + [System.IO.File]::WriteAllText($f, '') + if ($AgeSeconds -gt 0) { + (Get-Item -LiteralPath $f).LastWriteTime = (Get-Date).AddSeconds(-$AgeSeconds) + } +} +function Set-CachedParent { + param([string]$Root, [string]$Child, [string]$Parent) + $dir = [System.IO.Path]::Combine($Root, '.rogue', 'cursor-parent') + New-Item -ItemType Directory -Path $dir -Force | Out-Null + [System.IO.File]::WriteAllText([System.IO.Path]::Combine($dir, $Child), $Parent) +} + +$WS = '/Users/test/work/proj' +$SLUG = 'Users-test-work-proj' +function New-Payload { + param([string]$Id) + # transcript_path is null here exactly as it is on real events, INCLUDING + # ordinary parent ones; nothing in the dispatcher may branch on it. + return ('{"conversation_id":"' + $Id + '","session_id":"' + $Id + + '","workspace_roots":["' + $WS + '"],"transcript_path":null}') +} + +# ── Slug derivation ──────────────────────────────────────────────────────── +Assert-Eq (Get-RogueWorkspaceSlug (New-Payload 'x')) $SLUG 'slug strips the leading / and maps / and . to -' +Assert-Eq (Get-RogueWorkspaceSlug '{"workspace_roots":["/a/b.c"]}') 'a-b-c' 'slug maps a dotted path segment' +Assert-Eq (Get-RogueWorkspaceSlug '{"conversation_id":"x"}') '' 'no workspace_roots yields an empty slug' +Assert-Eq (Get-RogueWorkspaceSlug 'not json') '' 'unparseable payload yields an empty slug' + +# ── Conversation id validation ───────────────────────────────────────────── +# The id becomes a path component, so anything outside the uuid charset is +# rejected rather than looked up. +Assert-True (Test-RogueConversationId 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa') 'a uuid is a conversation id' +Assert-True (-not (Test-RogueConversationId '../../etc/passwd')) 'a traversal-shaped id is rejected' +Assert-True (-not (Test-RogueConversationId '')) 'an empty id is rejected' +Assert-True (-not (Test-RogueConversationId 'a b')) 'an id with a space is rejected' + +# ── Lookup: the filename IS the key ──────────────────────────────────────── +$h = New-TestHome +$childA = 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa' +$parentA = '99999999-9999-4999-8999-999999999999' +New-ChildFile $h $SLUG $parentA $childA +Assert-Eq (Get-RogueCursorParent $childA $SLUG) $parentA 'parent is the agent-transcripts directory name' +Assert-Null (Get-RogueCursorParent 'ffffffff-0000-4000-8000-ffffffffffff' $SLUG) 'a child with no file resolves to nothing' + +# Slug-scoping is an OPTIMIZATION: slug derivation has real exceptions on disk +# (numeric slugs, empty-window, .code-workspace-derived names), so a wrong slug +# still resolves through the global scan, which returns the SAME answer. +$h = New-TestHome +$childS = 'dddddddd-1111-4111-8111-dddddddddddd' +$parentS = '77777777-7777-4777-8777-777777777777' +New-ChildFile $h '1784115802260' $parentS $childS +Assert-Eq (Get-RogueCursorParent $childS $SLUG) $parentS 'resolved under a non-derivable slug via the global scan' +Assert-Eq (Get-RogueCursorParent $childS '') $parentS 'resolved with no slug at all' + +# Two concurrent subagents under ONE parent: each carries its own id, so each +# finds its own file. Any "newest file wins" rule would hand one of them the +# other's id, which is the single outcome that would make this feature wrong. +$h = New-TestHome +$childX = 'bbbbbbbb-1111-4111-8111-bbbbbbbbbbbb' +$childY = 'cccccccc-1111-4111-8111-cccccccccccc' +$parentXY = '88888888-8888-4888-8888-888888888888' +New-ChildFile $h $SLUG $parentXY $childX +New-ChildFile $h $SLUG $parentXY $childY +(Get-Item -LiteralPath ([System.IO.Path]::Combine($h, '.cursor', 'projects', $SLUG, 'agent-transcripts', $parentXY, 'subagents', ($childX + '.jsonl')))).LastWriteTime = (Get-Date).AddSeconds(-60) +Assert-Eq (Get-RogueCursorParent $childX $SLUG) $parentXY 'concurrent subagent X resolves the shared parent (older file)' +Assert-Eq (Get-RogueCursorParent $childY $SLUG) $parentXY 'concurrent subagent Y resolves the shared parent' + +# ── Marker gate: decides WHETHER to wait, never the answer ───────────────── +$h = New-TestHome +Assert-True (-not (Test-RogueSpawnMarkerLive $SLUG)) 'no marker directory means no live marker' +New-Marker $h $SLUG '33333333-3333-4333-8333-333333333333' +Assert-True (Test-RogueSpawnMarkerLive $SLUG) 'a fresh marker is live' +Assert-True (Test-RogueSpawnMarkerLive '') 'a fresh marker is found with no slug (unscoped scan)' +Assert-True (-not (Test-RogueSpawnMarkerLive 'some-other-workspace')) 'markers are scoped per workspace' + +$h = New-TestHome +New-Marker $h $SLUG '44444444-4444-4444-8444-444444444444' -AgeSeconds 600 +Assert-True (-not (Test-RogueSpawnMarkerLive $SLUG)) 'a marker past the TTL is treated as absent' + +# ── subagentStart writes the marker, subagentStop clears it ──────────────── +# subagentStart fires ON THE PARENT, so its conversation_id IS the parent's id. +$h = New-TestHome +$parentM = '33333333-3333-4333-8333-333333333333' +Write-RogueSpawnMarker (New-Payload $parentM) +$markerPath = [System.IO.Path]::Combine($h, '.rogue', 'cursor-spawn', $SLUG, $parentM) +Assert-True (Test-Path -LiteralPath $markerPath) 'subagentStart writes ~/.rogue/cursor-spawn//' +Remove-RogueSpawnMarker (New-Payload $parentM) +Assert-True (-not (Test-Path -LiteralPath $markerPath)) 'subagentStop clears the marker' + +# A non-uuid conversation id never becomes a path component. +$h = New-TestHome +Write-RogueSpawnMarker '{"conversation_id":"../../evil","workspace_roots":["/a"]}' +Assert-True (-not (Test-Path -LiteralPath ([System.IO.Path]::Combine($h, '.rogue', 'cursor-spawn')))) 'a traversal-shaped id writes no marker' + +# ── Resolution: cache-cold hit, and the cache is written ─────────────────── +$h = New-TestHome +New-ChildFile $h $SLUG $parentA $childA +$r = Resolve-RogueParentSession (New-Payload $childA) +Assert-Eq $r.Parent $parentA 'cache-cold resolution returns the parent' +Assert-Eq $r.Child $childA 'cache-cold resolution returns the child id' +$cacheFile = [System.IO.Path]::Combine($h, '.rogue', 'cursor-parent', $childA) +Assert-Eq ([System.IO.File]::ReadAllText($cacheFile)) $parentA 'resolution is cached at ~/.rogue/cursor-parent/' + +# A second event reuses the cache. Proven by DELETING the transcript tree first: +# only a cache read can still answer. A subagent fires 18-223 hooks per spawn and +# Cursor reuses a child id across re-spawns, so this is the common path. +Remove-Item -LiteralPath ([System.IO.Path]::Combine($h, '.cursor')) -Recurse -Force +$r = Resolve-RogueParentSession (New-Payload $childA) +Assert-Eq $r.Parent $parentA 'second event resolves from the cache, not the filesystem' + +# The cache is read BEFORE any scan: seed a parent that exists nowhere on disk. +$h = New-TestHome +$childC = '2c2c2c2c-1111-4111-8111-2c2c2c2c2c2c' +Set-CachedParent $h $childC 'cached-parent-id' +$r = Resolve-RogueParentSession (New-Payload $childC) +Assert-Eq $r.Parent 'cached-parent-id' 'cache is consulted before the filesystem' + +# ── Resolution: fail-open paths ──────────────────────────────────────────── +# No marker means NO WAIT AT ALL. A brand-new top-level conversation has no +# directory of its own for ~9s and so looks exactly like an unresolved child; +# without this gate every session start would pay the full budget. +$h = New-TestHome +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$r = Resolve-RogueParentSession (New-Payload '22222222-2222-2222-2222-222222222222') +$sw.Stop() +Assert-Null $r 'a main-agent conversation resolves to nothing' +Assert-True ($sw.Elapsed.TotalSeconds -lt 1) "no marker means no wait (took $([math]::Round($sw.Elapsed.TotalSeconds,2))s, budget is ~3s)" + +# A stale marker is treated as absent, so it does not arm the wait either. +$h = New-TestHome +New-Marker $h $SLUG '44444444-4444-4444-8444-444444444444' -AgeSeconds 600 +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$r = Resolve-RogueParentSession (New-Payload '0a0a0a0a-1111-4111-8111-0a0a0a0a0a0a') +$sw.Stop() +Assert-Null $r 'a stale marker resolves to nothing' +Assert-True ($sw.Elapsed.TotalSeconds -lt 1) 'a stale marker does not arm the wait' + +# Live marker, file never created: the budget expires and we fail open. +$h = New-TestHome +New-Marker $h $SLUG '55555555-5555-4555-8555-555555555555' +$env:ROGUE_CURSOR_PARENT_ITERS = '3' +$r = Resolve-RogueParentSession (New-Payload 'ffffffff-1111-4111-8111-ffffffffffff') +$env:ROGUE_CURSOR_PARENT_ITERS = $null +Assert-Null $r 'budget expiry resolves to nothing (fail open)' + +# Live marker, file appears mid-wait: the wait pays off. File creation is +# INDEPENDENT of hook returns (one spawn's file appeared 2.40s before any +# blocking hook fired), so this wait cannot self-deadlock. +$h = New-TestHome +$childW = 'eeeeeeee-1111-4111-8111-eeeeeeeeeeee' +$parentW = '66666666-6666-4666-8666-666666666666' +New-Marker $h $SLUG $parentW +$job = Start-Job -ScriptBlock { + param($root, $slug, $parent, $child) + Start-Sleep -Milliseconds 700 + $dir = [System.IO.Path]::Combine($root, '.cursor', 'projects', $slug, 'agent-transcripts', $parent, 'subagents') + New-Item -ItemType Directory -Path $dir -Force | Out-Null + [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 +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' + +# Malformed input never throws and never resolves. +$h = New-TestHome +Assert-Null (Resolve-RogueParentSession '{"conversation_id":"../../etc/passwd"}') 'a traversal-shaped id resolves to nothing' +Assert-Null (Resolve-RogueParentSession 'not json at all') 'an unparseable payload resolves to nothing' +Assert-Null (Resolve-RogueParentSession '{}') 'a payload with no conversation_id resolves to nothing' + +# ── teardown ─────────────────────────────────────────────────────────────── +foreach ($d in $homes) { Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue } +$env:USERPROFILE = $null + +Write-Host '' +if ($fails -gt 0) { + Write-Host "$fails of $count Cursor hook.ps1 assertions FAILED" + exit 1 +} +Write-Host "All $count Cursor hook.ps1 assertions passed." +exit 0 diff --git a/tests/test_hook_sh_cursor.sh b/tests/test_hook_sh_cursor.sh new file mode 100755 index 0000000..4376287 --- /dev/null +++ b/tests/test_hook_sh_cursor.sh @@ -0,0 +1,441 @@ +#!/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. +# +# The suite exists for the subagent → parent session attribution, and its +# LOAD-BEARING assertion is on every case: THE POSTED BODY IS BYTE-IDENTICAL TO +# THE PIPED STDIN. That property is what let us prove the empty-conversation_id +# bug belonged to Cursor and not to us, so all new information rides in HEADERS +# (`x-rogue-parent-session-id`, `x-rogue-agent-id`). The ONE permitted body +# exception is `rogueFilePreImageB64` on preToolUse, and case 12 asserts that +# exception explicitly so a second one cannot be added silently. +# +# The other invariant under test is that binding is DETERMINISTIC: the child's +# own conversation_id is looked up as a FILENAME under +# ~/.cursor/projects/*/agent-transcripts/*/subagents/, so two concurrent +# subagents each find their own file (case 4). Nothing here may ever become +# "pick the newest file", and the subagentStart marker decides only WHETHER TO +# WAIT, never the answer. +# +# Cursor runs `sh ./scripts/hook.sh `; 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)" +HOMES=() + +cleanup() { + if [ -n "${MOCK_PID:-}" ]; then + kill "$MOCK_PID" 2>/dev/null || true + wait "$MOCK_PID" 2>/dev/null || true # absorb the job-control "Terminated" notice + fi + rm -f "$ENV_FILE" "$HEADERS_FILE" "$OUT_FILE" + for h in "${HOMES[@]:-}"; do [ -n "$h" ] && rm -rf "$h"; done + [ -n "${NOJQ_DIR:-}" ] && rm -rf "$NOJQ_DIR" + return 0 +} +trap cleanup EXIT + +cat > "$ENV_FILE" < "$1/.cursor/projects/$2/agent-transcripts/$3/subagents/$4.jsonl" +} + +# $1 home, $2 slug, $3 parent id — the subagentStart marker (mtime is the clock). +make_marker() { + mkdir -p "$1/.rogue/cursor-spawn/$2" + : > "$1/.rogue/cursor-spawn/$2/$3" +} + +# $1 home, $2 child id, $3 parent id — pre-seed the resolution cache. +seed_cache() { + mkdir -p "$1/.rogue/cursor-parent" + printf '%s' "$3" > "$1/.rogue/cursor-parent/$2" +} + +# $1 conversation id — a minimal Cursor tool payload. `transcript_path` is null +# here exactly as it is on real events, INCLUDING ordinary parent ones; nothing +# in the dispatcher may branch on it. +payload_for() { + printf '{"conversation_id":"%s","session_id":"%s","workspace_roots":["%s"],"transcript_path":null}' \ + "$1" "$1" "$WS" +} + +# $1 home, $2 event, $3 payload. Pipes the payload as raw bytes (no heredoc +# newline) so the body-identity assertion is exact. Clears ROGUE_* from the +# process env so only the env file drives credential resolution. TEST_PATH, when +# set, REPLACES the dispatcher's PATH (see make_nojq_path). +LAST_PAYLOAD="" +TEST_PATH="" +run_hook() { + local rc + LAST_PAYLOAD="$3" + set +e + printf '%s' "$3" | env \ + HOME="$1" \ + ROGUE_API_KEY='' ROGUE_ACTOR_EMAIL='' ROGUE_ACTOR_NAME='' ROGUE_BASE_URL='' \ + ROGUE_CURSOR_PARENT_ITERS="${ROGUE_CURSOR_PARENT_ITERS:-}" \ + PATH="${TEST_PATH:-$PATH}" \ + "$SH" "$HOOK" "$2" > "$OUT_FILE" + rc=$? + set -e + return $rc +} + +# A PATH holding everything the dispatcher needs EXCEPT jq, so its anchored +# text-scan fallbacks run instead. 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. +make_nojq_path() { + local d b src + d="$(mktemp -d)" + for b in "$SH" sh dirname basename date mkdir cat sed grep tr head base64 sleep curl stat rm; 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" +} + +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 +} + +# mock_server.py OVERWRITES the record file per request, so every case that +# asserts on a POST must restart (or at least re-clear) between assertions. +restart_mock() { + [ -n "${MOCK_PID:-}" ] && kill "$MOCK_PID" 2>/dev/null || true + wait "$MOCK_PID" 2>/dev/null || true + rm -f "$HEADERS_FILE" + start_mock "$@" +} + +assert_eq() { + if [ "$1" != "$2" ]; then echo "FAIL [$3]: expected <$2> but got <$1>" >&2; exit 1; fi + echo " ok: $3" +} + +posted_body() { + python3 -c 'import json,sys; sys.stdout.write(json.load(open(sys.argv[1]))["body"])' "$HEADERS_FILE" +} + +assert_header() { + local actual + actual=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["headers"].get(sys.argv[2], ""))' "$HEADERS_FILE" "$1") + assert_eq "$actual" "$2" "$3" +} + +assert_no_header() { + local actual + actual=$(python3 -c 'import json,sys; print(sys.argv[2] in json.load(open(sys.argv[1]))["headers"])' "$HEADERS_FILE" "$1") + assert_eq "$actual" "False" "$2" +} + +# THE load-bearing assertion. Compares the raw bytes the mock recorded against +# the exact bytes we piped in. +assert_body_identical() { + local got + got="$(posted_body)" + assert_eq "$got" "$LAST_PAYLOAD" "$1" +} + +# Neither identity header, in either direction — a main-agent event must look +# exactly like today's. +assert_no_identity_headers() { + assert_no_header "x-rogue-parent-session-id" "$1 (no parent header)" + assert_no_header "x-rogue-agent-id" "$1 (no agent header)" +} + +now_s() { date +%s; } + +# ── Case 1: baseline relay — verbatim body, existing headers, no identity ── +start_mock '{}' +HOME1="$(new_home)" +run_hook "$HOME1" postToolUse "$(payload_for 11111111-1111-1111-1111-111111111111)" +assert_eq "$(cat "$OUT_FILE")" '{}' "allow response relayed verbatim" +assert_header "x-rogue-event" "postToolUse" "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-actor-name" "Test User" "x-rogue-actor-name forwarded (with space)" +assert_header "x-rogue-source" "cursor" "x-rogue-source: cursor" +path=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["path"])' "$HEADERS_FILE") +assert_eq "$path" "/api/v1/hooks/cursor" "POST path is /api/v1/hooks/cursor" +assert_body_identical "unresolved body posted byte-identical to stdin" + +# ── Case 2: a main-agent conversation resolves nothing and never waits ───── +# A brand-new top-level conversation looks exactly like an unresolved child (its +# own agent-transcripts dir does not exist for ~9s), so the ONLY thing keeping it +# from paying the full budget is the marker gate. +restart_mock '{}' +HOME2="$(new_home)" +t0=$(now_s) +run_hook "$HOME2" preToolUse "$(payload_for 22222222-2222-2222-2222-222222222222)" +t1=$(now_s) +assert_no_identity_headers "main-agent event" +assert_body_identical "main-agent body posted byte-identical to stdin" +if [ $((t1 - t0)) -ge 2 ]; then + echo "FAIL [main-agent event returns promptly]: took $((t1 - t0))s, budget is ~3s" >&2; exit 1 +fi +echo " ok: main-agent event returns promptly (no marker -> no wait)" + +# ── Case 3: cache-cold resolution — both headers, parent is the DIR name ─── +restart_mock '{}' +HOME3="$(new_home)" +CHILD_A=aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa +PARENT_A=99999999-9999-4999-8999-999999999999 +make_child_file "$HOME3" "$SLUG" "$PARENT_A" "$CHILD_A" +run_hook "$HOME3" postToolUse "$(payload_for "$CHILD_A")" +assert_header "x-rogue-parent-session-id" "$PARENT_A" "parent header is the agent-transcripts directory name" +assert_header "x-rogue-agent-id" "$CHILD_A" "agent header is the child's OWN conversation id" +assert_body_identical "resolved child body posted byte-identical to stdin" +assert_eq "$(cat "$HOME3/.rogue/cursor-parent/$CHILD_A")" "$PARENT_A" "resolution cached at ~/.rogue/cursor-parent/" + +# ── Case 3b: a second event for the same child reuses the cache ─────────── +# Proven by DELETING the transcript file first: only a cache read can still +# answer. A subagent fires 18-223 hooks per spawn, so this is the common path. +restart_mock '{}' +rm -rf "$HOME3/.cursor/projects" +run_hook "$HOME3" beforeShellExecution "$(payload_for "$CHILD_A")" +assert_header "x-rogue-parent-session-id" "$PARENT_A" "second event resolves from the cache, not the filesystem" +assert_header "x-rogue-agent-id" "$CHILD_A" "second event still carries the child id" +assert_body_identical "cached-resolution body posted byte-identical to stdin" + +# ── Case 4: two subagents under ONE parent — each carries its OWN id ─────── +# This is the case that proves the lookup is a KEY LOOKUP and not "newest file +# wins": both files live in the same subagents/ directory, so any ranking rule +# would hand at least one event the other child's id. +restart_mock '{}' +HOME4="$(new_home)" +CHILD_X=bbbbbbbb-1111-4111-8111-bbbbbbbbbbbb +CHILD_Y=cccccccc-1111-4111-8111-cccccccccccc +PARENT_XY=88888888-8888-4888-8888-888888888888 +make_child_file "$HOME4" "$SLUG" "$PARENT_XY" "$CHILD_X" +sleep 0.05 # distinct mtimes, so a "newest wins" bug would be deterministic +make_child_file "$HOME4" "$SLUG" "$PARENT_XY" "$CHILD_Y" + +run_hook "$HOME4" postToolUse "$(payload_for "$CHILD_X")" +assert_header "x-rogue-agent-id" "$CHILD_X" "concurrent subagent X carries X's id (older file)" +assert_header "x-rogue-parent-session-id" "$PARENT_XY" "concurrent subagent X resolves the shared parent" +restart_mock '{}' +run_hook "$HOME4" postToolUse "$(payload_for "$CHILD_Y")" +assert_header "x-rogue-agent-id" "$CHILD_Y" "concurrent subagent Y carries Y's id" +restart_mock '{}' +run_hook "$HOME4" afterFileEdit "$(payload_for "$CHILD_X")" +assert_header "x-rogue-agent-id" "$CHILD_X" "X still carries X's id after Y ran (no cross-talk)" + +# ── Case 5: a child under an UNDERIVABLE slug — global glob fallback ─────── +# Slug derivation has real exceptions on disk (numeric slugs, `empty-window`, +# `.code-workspace`-derived names). The filename is the key either way. +restart_mock '{}' +HOME5="$(new_home)" +CHILD_S=dddddddd-1111-4111-8111-dddddddddddd +PARENT_S=77777777-7777-4777-8777-777777777777 +make_child_file "$HOME5" "1784115802260" "$PARENT_S" "$CHILD_S" +run_hook "$HOME5" postToolUse "$(payload_for "$CHILD_S")" +assert_header "x-rogue-parent-session-id" "$PARENT_S" "resolved under a non-derivable slug via the global glob" +assert_header "x-rogue-agent-id" "$CHILD_S" "child id unchanged by the fallback path" + +# ── Case 6: live marker + file created mid-wait -> headers sent ──────────── +# The child's file is born 0.811-1.627s after its first hook, and its creation is +# INDEPENDENT of hook returns, so this wait cannot self-deadlock. +restart_mock '{}' +HOME6="$(new_home)" +CHILD_W=eeeeeeee-1111-4111-8111-eeeeeeeeeeee +PARENT_W=66666666-6666-4666-8666-666666666666 +make_marker "$HOME6" "$SLUG" "$PARENT_W" +( sleep 0.7; make_child_file "$HOME6" "$SLUG" "$PARENT_W" "$CHILD_W" ) & +SPAWNER=$! +run_hook "$HOME6" postToolUse "$(payload_for "$CHILD_W")" +wait "$SPAWNER" 2>/dev/null || true +assert_header "x-rogue-parent-session-id" "$PARENT_W" "live marker: waited and resolved once the file appeared" +assert_header "x-rogue-agent-id" "$CHILD_W" "mid-wait resolution carries the child id" +assert_body_identical "mid-wait body posted byte-identical to stdin" + +# ── Case 7: live marker, file NEVER created -> fail open, POST still happens ── +restart_mock '{}' +HOME7="$(new_home)" +CHILD_N=ffffffff-1111-4111-8111-ffffffffffff +make_marker "$HOME7" "$SLUG" 55555555-5555-4555-8555-555555555555 +ROGUE_CURSOR_PARENT_ITERS=5 run_hook "$HOME7" postToolUse "$(payload_for "$CHILD_N")" +assert_eq "$(cat "$OUT_FILE")" '{}' "budget expiry still relays the response (fail open)" +assert_no_identity_headers "budget expired" +assert_body_identical "fail-open body posted byte-identical to stdin" + +# ── Case 8: a marker older than the TTL is treated as absent (no wait) ───── +restart_mock '{}' +HOME8="$(new_home)" +CHILD_T=0a0a0a0a-1111-4111-8111-0a0a0a0a0a0a +make_marker "$HOME8" "$SLUG" 44444444-4444-4444-8444-444444444444 +touch -t 200001010000 "$HOME8/.rogue/cursor-spawn/$SLUG/44444444-4444-4444-8444-444444444444" +t0=$(now_s) +run_hook "$HOME8" postToolUse "$(payload_for "$CHILD_T")" +t1=$(now_s) +assert_no_identity_headers "stale marker" +if [ $((t1 - t0)) -ge 2 ]; then + echo "FAIL [stale marker does not arm the wait]: took $((t1 - t0))s" >&2; exit 1 +fi +echo " ok: stale marker does not arm the wait" + +# ── Case 9: subagentStart writes the marker named by its OWN conversation_id ── +# subagentStart fires ON THE PARENT, so its conversation_id IS the parent's, and +# it must send no identity headers of its own. +restart_mock '{}' +HOME9="$(new_home)" +PARENT_M=33333333-3333-4333-8333-333333333333 +run_hook "$HOME9" subagentStart "$(payload_for "$PARENT_M")" +assert_no_identity_headers "subagentStart" +assert_body_identical "subagentStart body posted byte-identical to stdin" +if [ ! -f "$HOME9/.rogue/cursor-spawn/$SLUG/$PARENT_M" ]; then + echo "FAIL [subagentStart writes the marker]: no marker at ~/.rogue/cursor-spawn/$SLUG/$PARENT_M" >&2; exit 1 +fi +echo " ok: subagentStart writes ~/.rogue/cursor-spawn//" + +# ── Case 9b: subagentStop clears it (best effort; the TTL is the real retire) ── +restart_mock '{}' +run_hook "$HOME9" subagentStop "$(payload_for "$PARENT_M")" +if [ -f "$HOME9/.rogue/cursor-spawn/$SLUG/$PARENT_M" ]; then + echo "FAIL [subagentStop clears the marker]: marker still present" >&2; exit 1 +fi +echo " ok: subagentStop clears the marker" +assert_no_identity_headers "subagentStop" + +# ── Case 10: parent-side events never resolve, even with a file on disk ──── +# sessionEnd carries the PARENT's own conversation id; attributing it to itself +# would be meaningless, and waiting on it would tax every session. +restart_mock '{}' +HOME10="$(new_home)" +CHILD_P=1b1b1b1b-1111-4111-8111-1b1b1b1b1b1b +make_child_file "$HOME10" "$SLUG" 22222222-2222-4222-8222-222222222222 "$CHILD_P" +run_hook "$HOME10" sessionEnd "$(payload_for "$CHILD_P")" +assert_no_identity_headers "sessionEnd (parent-side event)" + +# ── Case 11: the cache is read BEFORE any filesystem scan ───────────────── +# Seeded with a parent that has no file anywhere on disk, so only a cache read +# can produce this header. +restart_mock '{}' +HOME11="$(new_home)" +CHILD_C=2c2c2c2c-1111-4111-8111-2c2c2c2c2c2c +seed_cache "$HOME11" "$CHILD_C" cached-parent-id +run_hook "$HOME11" postToolUse "$(payload_for "$CHILD_C")" +assert_header "x-rogue-parent-session-id" "cached-parent-id" "cache is consulted before the filesystem" +assert_header "x-rogue-agent-id" "$CHILD_C" "cache hit still carries the child id" + +# ── Case 12: preToolUse is the ONE permitted body mutation ──────────────── +# A resolved child's preToolUse may add `rogueFilePreImageB64` and NOTHING else. +# Asserting the exception by name is what stops a second one being added quietly. +restart_mock '{}' +HOME12="$(new_home)" +CHILD_E=3d3d3d3d-1111-4111-8111-3d3d3d3d3d3d +PARENT_E=11111111-1111-4111-8111-111111111111 +make_child_file "$HOME12" "$SLUG" "$PARENT_E" "$CHILD_E" +TARGET="$HOME12/target.txt" +printf 'pre-edit content' > "$TARGET" +PRE_PAYLOAD=$(python3 -c ' +import json,sys +print(json.dumps({ + "conversation_id": sys.argv[1], + "session_id": sys.argv[1], + "workspace_roots": [sys.argv[3]], + "transcript_path": None, + "tool_name": "Write", + "tool_input": {"file_path": sys.argv[2], "content": "new"}, +}, separators=(",", ":")))' "$CHILD_E" "$TARGET" "$WS") +run_hook "$HOME12" preToolUse "$PRE_PAYLOAD" +assert_header "x-rogue-parent-session-id" "$PARENT_E" "preToolUse still resolves the parent" +assert_header "x-rogue-agent-id" "$CHILD_E" "preToolUse still carries the child id" +diff_keys=$(posted_body | python3 -c ' +import base64,json,sys +posted = json.load(sys.stdin) +sent = json.loads(sys.argv[1]) +added = sorted(set(posted) - set(sent)) +removed = sorted(set(sent) - set(posted)) +changed = sorted(k for k in sent if posted.get(k) != sent[k]) +b64 = posted.get("rogueFilePreImageB64", "") +ok_pre = base64.b64decode(b64).decode() == "pre-edit content" +print("added=%s removed=%s changed=%s preimage_ok=%s" % (added, removed, changed, ok_pre))' "$PRE_PAYLOAD") +assert_eq "$diff_keys" \ + "added=['rogueFilePreImageB64'] removed=[] changed=[] preimage_ok=True" \ + "preToolUse adds rogueFilePreImageB64 and nothing else (the only body exception)" + +# ── Case 13: a non-preToolUse event NEVER carries a pre-image ───────────── +restart_mock '{}' +run_hook "$HOME12" afterFileEdit "$PRE_PAYLOAD" +assert_body_identical "afterFileEdit body posted byte-identical to stdin (no pre-image)" + +# ── Case 14: a non-uuid conversation_id is never used as a path component ── +restart_mock '{}' +HOME14="$(new_home)" +run_hook "$HOME14" postToolUse '{"conversation_id":"../../etc/passwd","session_id":"x","workspace_roots":["'"$WS"'"]}' +assert_no_identity_headers "non-uuid conversation_id" +assert_body_identical "traversal-shaped id body posted byte-identical to stdin" + +# ── Case 15: an unparseable payload fails open and still relays ─────────── +restart_mock '{"permission":"deny"}' +HOME15="$(new_home)" +run_hook "$HOME15" postToolUse 'not json at all' +assert_eq "$(cat "$OUT_FILE")" '{"permission":"deny"}' "unparseable payload still relays the response" +assert_no_identity_headers "unparseable payload" +assert_body_identical "unparseable payload posted byte-identical to stdin" + +# ── Case 16: resolution works with jq absent (the text-scan fallback) ───── +# jq is missing from older macOS and minimal Linux images, so `conversation_id` +# and `workspace_roots[0]` both have anchored-scan fallbacks. Only ONE path runs +# on a given machine, so the fallback needs its own coverage. +restart_mock '{}' +HOME16="$(new_home)" +CHILD_J=4e4e4e4e-1111-4111-8111-4e4e4e4e4e4e +PARENT_J=5f5f5f5f-5555-4555-8555-5f5f5f5f5f5f +make_child_file "$HOME16" "$SLUG" "$PARENT_J" "$CHILD_J" +NOJQ_DIR="$(make_nojq_path)" +TEST_PATH="$NOJQ_DIR" run_hook "$HOME16" postToolUse "$(payload_for "$CHILD_J")" +assert_header "x-rogue-parent-session-id" "$PARENT_J" "resolved without jq (anchored text scan)" +assert_header "x-rogue-agent-id" "$CHILD_J" "child id read without jq" +assert_body_identical "no-jq body posted byte-identical to stdin" +rm -rf "$NOJQ_DIR"; NOJQ_DIR="" + +echo +echo "All Cursor hook.sh tests passed (SH=$SH)." diff --git a/tests/test_hooks_json_cursor.sh b/tests/test_hooks_json_cursor.sh new file mode 100755 index 0000000..1703716 --- /dev/null +++ b/tests/test_hooks_json_cursor.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# tests/test_hooks_json_cursor.sh — static lint of the Cursor plugin's +# hooks.json. Asserts the dual-dispatcher registration (one `sh` entry + one +# PowerShell entry per event, so exactly one does real work per machine) and the +# per-hook timeout. +# +# The 120s timeout is LOAD-BEARING, not decoration: a subagent's first event may +# wait up to ~3s for Cursor to write the child's transcript file (see +# resolve_parent_session in scripts/hook.sh). 3s is 2.5% of this budget; cutting +# the timeout towards the wait would turn a resolvable subagent into a killed +# hook. +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +HOOKS="$REPO/plugins/cursor/hooks/hooks.json" + +python3 - "$HOOKS" <<'PY' +import json, sys + +path = sys.argv[1] +with open(path) as f: + doc = json.load(f) # raises on invalid JSON + +errors = [] + +if doc.get("version") != 1: + errors.append(f"top-level version must be 1, got {doc.get('version')!r}") + +hooks = doc.get("hooks", {}) +expected_events = { + "sessionStart", "sessionEnd", "beforeSubmitPrompt", "preToolUse", + "postToolUse", "postToolUseFailure", "beforeShellExecution", + "afterShellExecution", "beforeMCPExecution", "afterMCPExecution", + "beforeReadFile", "afterFileEdit", "afterAgentResponse", "afterAgentThought", + "subagentStart", "subagentStop", "stop", "preCompact", +} +got_events = set(hooks.keys()) +if got_events != expected_events: + missing = sorted(expected_events - got_events) + extra = sorted(got_events - expected_events) + errors.append(f"events differ: missing={missing} extra={extra}") + +# subagentStart is what arms the marker gate, and subagentStop is what clears it. +# Losing either registration silently costs every subagent its attribution, so +# call them out by name rather than leaving them inside the set comparison. +for required in ("subagentStart", "subagentStop"): + if required not in got_events: + errors.append(f"'{required}' must stay registered (it drives the spawn marker)") + +for event, entries in sorted(hooks.items()): + if not isinstance(entries, list) or len(entries) != 2: + errors.append(f"{event}: expected exactly 2 entries (sh + PowerShell)") + continue + sh_entries = [e for e in entries if e.get("command", "").startswith("sh ./scripts/hook.sh ")] + ps_entries = [e for e in entries if e.get("command", "").startswith("powershell ")] + if len(sh_entries) != 1: + errors.append(f"{event}: expected exactly 1 sh entry") + if len(ps_entries) != 1: + errors.append(f"{event}: expected exactly 1 PowerShell entry") + for i, entry in enumerate(entries): + tag = f"{event}[{i}]" + cmd = entry.get("command") + if not isinstance(cmd, str) or not cmd: + errors.append(f"{tag}: missing 'command'") + continue + # The event name is passed as the dispatcher's argument and echoed back + # as x-rogue-event; the server routes on it. The PowerShell entry closes + # its -Command string after the argument, hence the trailing quote. + if not cmd.rstrip().rstrip('"').endswith(event): + errors.append(f"{tag}: command must end with the event name {event!r}") + if entry.get("timeout") != 120: + errors.append(f"{tag}: timeout must be 120 (the ~3s subagent wait lives inside it)") + +if errors: + for e in errors: + print(f"FAIL: {e}") + sys.exit(1) + +print(f" ok: {len(hooks)} events, each with an sh + PowerShell entry at timeout 120") +PY + +echo +echo "Cursor hooks.json lint passed."