diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index f41758a..89664f4 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "rogue-copilot", - "version": "1.2.3", + "version": "1.2.4", "description": "Rogue Security extensions for GitHub Copilot CLI", "owner": { "name": "Qualifire (Rogue Security)", @@ -10,7 +10,7 @@ "plugins": [ { "name": "rogue", - "version": "1.2.3", + "version": "1.2.4", "description": "Rogue Security AIDR — real-time AI agent detection and response for GitHub Copilot CLI", "author": { "name": "Rogue Security", diff --git a/plugins/copilot/plugin.json b/plugins/copilot/plugin.json index 9f5c66c..1a33089 100644 --- a/plugins/copilot/plugin.json +++ b/plugins/copilot/plugin.json @@ -1,6 +1,6 @@ { "name": "rogue", - "version": "1.2.3", + "version": "1.2.4", "description": "Rogue Security AIDR — real-time AI agent detection and response for GitHub Copilot CLI", "author": { "name": "Rogue Security", diff --git a/plugins/copilot/scripts/hook.ps1 b/plugins/copilot/scripts/hook.ps1 index 51f8351..ea88592 100644 --- a/plugins/copilot/scripts/hook.ps1 +++ b/plugins/copilot/scripts/hook.ps1 @@ -255,85 +255,8 @@ try { } catch { Dbg "notify failed: $($_.Exception.Message)" } } -# ── Subagent body tag (mirrors hook.sh augment_with_agent_tag) ───────────── -# Add the subagent tag to the BODY of a re-attributed event: "agentId" (the bare -# tool-call id) and "agentNameB64" (base64 of the UTF-8 display name). The name is -# arbitrary vendor text — one '"' or '\' would corrupt the payload — so it travels -# base64-encoded, the same trick as transcriptTailB64; base64 has no JSON-special -# characters, so appending it by re-closing the object is safe. Omitted when the -# name is unknown. The backend reads both fields off the payload (they used to -# ride as x-rogue-agent-* headers). -# -# TWO mutation paths, which must agree byte-for-byte with hook.sh's on a compact -# payload (only one ever runs on a given machine): -# 1. jq when it is on PATH — a real JSON edit. jq re-serializes, so a -# pretty-printed vendor payload comes back compacted; semantically identical, -# and Copilot sends compact JSON. -# 2. otherwise the same string concat used for transcriptTailB64 — no parse, so -# the vendor's bytes are preserved exactly. -# Deliberately NOT ConvertTo-Json on the whole payload: a full parse + reserialize -# could alter the vendor's JSON in ways we don't control (ConvertTo-Json also -# truncates below its default -Depth 2). Fail-open everywhere: a bad id, a jq -# failure, or a body that is not an object returns the body unchanged (we lose -# attribution, never the relay). -function Add-AgentTag { - param([string]$Body, [string]$Id, [string]$Name) - try { - # The id is a bare token from Copilot (toolu_… / call_…). Anything outside - # the token charset is not one — skip BOTH fields rather than risk a - # corrupt body. - if (-not $Id -or ($Id -notmatch '^[A-Za-z0-9_-]+$')) { return $Body } - $nb64 = '' - if ($Name) { $nb64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Name)) } - - if (Get-Command jq -ErrorAction SilentlyContinue) { - # Pipe/read as UTF-8 explicitly: the default native-command encoding is - # the OEM code page on PS 5.1, which would mangle non-ASCII payload text - # on the round trip through jq — a silent body corruption. - $prevOut = $OutputEncoding - $prevConsole = $null - try { $prevConsole = [Console]::OutputEncoding } catch {} - $out = '' - try { - $utf8 = New-Object System.Text.UTF8Encoding($false) - $OutputEncoding = $utf8 - try { [Console]::OutputEncoding = $utf8 } catch {} - # Values are double-quoted so PowerShell passes them as single - # literal arguments (base64 carries '+', '/' and '='); the jq filter - # is single-quoted so PS leaves its $id/$nb64 refs alone. - if ($nb64) { - $out = ($Body | & jq -c --arg id "$Id" --arg nb64 "$nb64" '. + {agentId:$id,agentNameB64:$nb64}' 2>$null) -join '' - } else { - $out = ($Body | & jq -c --arg id "$Id" '. + {agentId:$id}' 2>$null) -join '' - } - } finally { - $OutputEncoding = $prevOut - if ($prevConsole) { try { [Console]::OutputEncoding = $prevConsole } catch {} } - } - # Only trust a complete object back; anything else (invalid JSON in, jq - # error, a non-object payload) falls through to the concat path. - if ($out -and $out.StartsWith('{') -and $out.EndsWith('}')) { return $out } - } - - # Trim trailing whitespace so the single-'}' strip lands on the real closing - # brace, then strip exactly ONE '}' (TrimEnd('}') would strip ALL of them and - # corrupt a body ending in "}}") — mirrors hook.sh. - $p = $Body.TrimEnd() - if (-not $p.EndsWith('}')) { return $Body } # not an object → leave it alone - $p = $p.Substring(0, $p.Length - 1) - # An empty object needs no separator ({} → {"agentId":…}); jq agrees. - $sep = ',' - if ($p -eq '{') { $sep = '' } - if ($nb64) { return $p + $sep + '"agentId":"' + $Id + '","agentNameB64":"' + $nb64 + '"}' } - return $p + $sep + '"agentId":"' + $Id + '"}' - } catch { - Dbg "agent tag failed: $($_.Exception.Message)" - return $Body - } -} - # Test seam: dot-sourcing with ROGUE_PS_LIB_ONLY=1 loads the functions above -# (Sanitize, Log, Test-JetBrainsIde, Show-BlockNotification, Add-AgentTag, +# (Sanitize, Log, Test-JetBrainsIde, Show-BlockNotification, # ConvertFrom-ShellQuoted) without running the dispatcher. Production never sets # this, so the hook always runs its main body. if ($env:ROGUE_PS_LIB_ONLY) { return } @@ -426,9 +349,9 @@ $payload = $payload.TrimStart([char]0xFEFF) # they orphan into a separate audit log. The parent link lives only in the # parent session's events.jsonl (a subagent.started line naming this id; the # parent id IS that transcript's directory name). Resolve it, rewrite the -# outgoing sessionId, and tag with the agentId/agentNameB64 BODY fields (see -# Add-AgentTag — the tag used to travel as x-rogue-agent-* headers). Fail-open: -# unresolved → body untouched (today's orphaned behavior — never worse). +# outgoing sessionId, and tag via the x-rogue-agent-id / x-rogue-agent-name-b64 +# headers (see the POST below). Fail-open: unresolved → body untouched (today's +# orphaned behavior — never worse). $subagentId = '' $subagentName = '' $copilotStateDir = $env:ROGUE_COPILOT_STATE_DIR @@ -492,10 +415,6 @@ try { $subagentId = $sid $subagentName = $map.Name $payload = $payload -replace ('"sessionId"\s*:\s*"' + [regex]::Escape($sid) + '"'), ('"sessionId":"' + $map.Parent + '"') - # Tag the (now correctly-attributed) body so the backend can mark these - # rows as a subagent's. Before the tail append, so the field order is - # stable across events (mirrors hook.sh). - $payload = Add-AgentTag $payload $subagentId $subagentName Log "subagent=$sid parent=$($map.Parent)" } else { Log "subagent=$sid outcome=unresolved" @@ -676,11 +595,27 @@ $headers = @{ 'x-rogue-version' = $pluginVersion 'x-rogue-agent' = 'github_copilot' } -# The subagent tag rides in the BODY (agentId/agentNameB64 — see Add-AgentTag), so -# every event POSTs the same fixed headers. The local $subagent* variables keep -# Copilot's own terminology, since Copilot is what calls these subagents; the wire -# field names match the backend's agentId/agentName and the -# aidr_message.agent_id/agent_name columns they land in. +# Every event POSTs the same seven headers; a re-attributed subagent event adds the +# agent tag as two more — x-rogue-agent-id and x-rogue-agent-name-b64, the same +# pair the Antigravity dispatcher sends. In HEADERS and not in the body so the +# POSTed event stays the vendor's own bytes. The name is base64 because a display +# name is arbitrary vendor text and HTTP header values are ISO-8859-1 by spec, so +# an accent or an emoji sent raw is undefined behavior across proxies. Both are +# omitted entirely, never sent empty, on a main-agent event. The local $subagent* +# variables keep Copilot's own terminology, since Copilot is what calls these +# subagents; the wire names match the aidr_message.agent_id/agent_name columns +# they land in. Mirrors hook.sh. +# +# The id is a bare token from Copilot (toolu_… / call_…); anything outside the +# token charset is not one, so BOTH headers are skipped rather than emitting a +# junk value. +if ($subagentId -and ($subagentId -match '^[A-Za-z0-9_-]+$')) { + $headers['x-rogue-agent-id'] = $subagentId + if ($subagentName) { + $headers['x-rogue-agent-name-b64'] = + [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($subagentName)) + } +} $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($payload) $resp = '' try { diff --git a/plugins/copilot/scripts/hook.sh b/plugins/copilot/scripts/hook.sh index f10e893..5b995b6 100755 --- a/plugins/copilot/scripts/hook.sh +++ b/plugins/copilot/scripts/hook.sh @@ -10,10 +10,11 @@ # which the IDE honors but renders nowhere, so we additionally show a local # alert (see in_jetbrains_ide / notify_block) while still relaying the body # unchanged. There are exactly TWO stdin enrichments: a re-attributed subagent -# event gets its sessionId rewritten plus agentId/agentNameB64 added (see -# reattribute_subagent / augment_with_agent_tag), and agentStop/subagentStop -# additionally get the transcript tail appended (see augment_with_transcript) so -# the backend can read the final message. +# event gets its sessionId rewritten (see reattribute_subagent), and +# agentStop/subagentStop get the transcript tail appended (see +# augment_with_transcript) so the backend can read the final message. The +# subagent's agent tag is NOT one of them — it rides in the x-rogue-agent-* +# headers. # # Copilot selects the `bash` command on macOS/Linux and the `powershell` command # on Windows (see hooks.json), so — unlike the Claude bridge — there is no @@ -279,11 +280,12 @@ augment_with_transcript() { # `subagent.started` line records this id as its toolCallId/agentId — and the # parent session id IS that transcript's directory name. Resolve it and rewrite # the outgoing sessionId so the subagent's turns land in the right session, -# tagged with the agentId/agentNameB64 BODY fields (see augment_with_agent_tag — -# the tag used to travel as x-rogue-agent-* headers). Fail-open: unresolved → -# leave the body untouched (i.e. today's orphaned behavior — never worse). +# tagged via the x-rogue-agent-id / x-rogue-agent-name-b64 headers (see the POST +# below). Fail-open: unresolved → leave the body untouched (i.e. today's orphaned +# behavior — never worse). SUBAGENT_ID="" SUBAGENT_NAME="" +SUBAGENT_NAME_B64="" COPILOT_STATE_DIR="${ROGUE_COPILOT_STATE_DIR:-$HOME/.copilot/session-state}" # $1 = subagent id. Echoes "\n" on success. @@ -340,6 +342,12 @@ reattribute_subagent() { SUBAGENT_NAME=$(printf '%s' "$_map" | sed -n '2p') [ -n "$_parent" ] || return SUBAGENT_ID="$_sid" + # The name travels base64-encoded: a display name is arbitrary vendor text, and + # HTTP header values are ISO-8859-1 by spec, so an accent or an emoji sent raw + # is undefined behavior across proxies. Encoded here, emitted at the POST below. + if [ -n "$SUBAGENT_NAME" ]; then + SUBAGENT_NAME_B64=$(printf '%s' "$SUBAGENT_NAME" | base64 2>/dev/null | tr -d '\r\n') + fi # Tolerate whitespace around the key/colon (a pretty-printed payload) and # normalize to compact form; a non-matching rewrite would leave the body # orphaned even though we resolved the parent. @@ -347,70 +355,6 @@ reattribute_subagent() { log "subagent=$_sid parent=$_parent name=$(sanitize "$SUBAGENT_NAME")" } -# Add the subagent tag to the BODY of a re-attributed event: "agentId" (the bare -# tool-call id) and "agentNameB64" (base64 of the UTF-8 display name). The name is -# arbitrary vendor text — one '"' or '\' would corrupt the payload — so it travels -# base64-encoded, the same trick as transcriptTailB64; base64 has no JSON-special -# characters, so appending it by re-closing the object is safe. Omitted when the -# name is unknown. The backend reads both fields off the payload (they used to -# ride as x-rogue-agent-* headers). -# -# TWO mutation paths, and they must agree byte-for-byte on a compact payload -# (tests/test_hook_sh_copilot.sh asserts exactly that, since only one path runs on -# any given machine): -# 1. jq when it is on PATH (macOS 26 ships /usr/bin/jq) — a real JSON edit. -# NOTE jq re-serializes, so a pretty-printed vendor payload comes back -# compacted; semantically identical, and Copilot sends compact JSON. -# 2. otherwise the same string concat used for transcriptTailB64 — no parse, so -# the vendor's bytes are preserved exactly. -# Fail-open everywhere: a bad id, a jq failure, or a body that is not an object -# returns the body unchanged (we lose attribution, never the relay). -# $1 = body; echoes the (possibly tagged) body. -augment_with_agent_tag() { - _body="$1" - # The id is a bare token from Copilot (toolu_… / call_…). Anything outside the - # token charset is not one — skip BOTH fields rather than risk a corrupt body. - case "$SUBAGENT_ID" in - ''|*[!A-Za-z0-9_-]*) printf '%s' "$_body"; return ;; - esac - _nb64="" - if [ -n "$SUBAGENT_NAME" ]; then - _nb64=$(printf '%s' "$SUBAGENT_NAME" | base64 2>/dev/null | tr -d '\r\n') - fi - - if command -v jq >/dev/null 2>&1; then - if [ -n "$_nb64" ]; then - _out=$(printf '%s' "$_body" | jq -c --arg id "$SUBAGENT_ID" --arg nb64 "$_nb64" \ - '. + {agentId:$id,agentNameB64:$nb64}' 2>/dev/null) - else - _out=$(printf '%s' "$_body" | jq -c --arg id "$SUBAGENT_ID" \ - '. + {agentId:$id}' 2>/dev/null) - fi - # Only trust a complete object back; anything else (invalid JSON in, jq error, - # a non-object payload) falls through to the concat path. - case "$_out" in - '{'*'}') printf '%s' "$_out"; return ;; - esac - fi - - # Trim trailing whitespace (a pretty-printed payload can end in spaces or a - # newline after the closing brace) so the single-'}' strip lands on the real - # closing brace — mirrors augment_with_transcript / hook.ps1's $payload.TrimEnd(). - _body="${_body%"${_body##*[![:space:]]}"}" - case "$_body" in - *'}') : ;; - *) printf '%s' "$1"; return ;; # not an object → leave it alone - esac - _pre="${_body%\}}" - # An empty object needs no separator ({} → {"agentId":…}); jq produces the same. - if [ "$_pre" = "{" ]; then _sep=""; else _sep=","; fi - if [ -n "$_nb64" ]; then - printf '%s%s"agentId":"%s","agentNameB64":"%s"}' "$_pre" "$_sep" "$SUBAGENT_ID" "$_nb64" - else - printf '%s%s"agentId":"%s"}' "$_pre" "$_sep" "$SUBAGENT_ID" - fi -} - # Not configured: emit the SessionStart hint (so the user knows to run setup) or a # clean allow for every other event. Never POST without a key. if [ -z "${ROGUE_API_KEY:-}" ]; then @@ -440,11 +384,6 @@ BODY="$(cat)" # Re-attribute a subagent's event to its parent session BEFORE any tail # augmentation (a subagent agentStop has no transcriptPath, so augment no-ops). reattribute_subagent -# Tag the (now correctly-attributed) body so the backend can mark these rows as a -# subagent's. Before the tail append, so the field order is stable across events. -if [ -n "$SUBAGENT_ID" ]; then - BODY="$(augment_with_agent_tag "$BODY")" -fi case "$EVENT" in agentStop|subagentStop) BODY="$(augment_with_transcript "$BODY")" ;; esac @@ -481,19 +420,38 @@ fi # Capture body + HTTP status. -w appends a final line ""; on any transport # failure curl exits non-zero and the code is 000. Relay the body ONLY on a clean # HTTP 200 so an error page (401/404/500) is never handed to Copilot as a decision. -# The subagent tag rides in the BODY (agentId/agentNameB64 — see -# augment_with_agent_tag), so every event POSTs the same fixed headers. The local -# SUBAGENT_* variables keep Copilot's own terminology, since Copilot is what calls -# these subagents; the wire field names match the backend's agentId/agentName and +# Every event POSTs the same seven headers; a re-attributed subagent event adds +# the agent tag as two more - x-rogue-agent-id and x-rogue-agent-name-b64, the +# same pair the Antigravity dispatcher sends. In HEADERS and not in the body so +# the POSTed event stays the vendor's own bytes: tagging the body meant a full jq +# re-serialization of arbitrary toolArgs. Both are omitted entirely, never sent +# empty, on a main-agent event. The local SUBAGENT_* variables keep Copilot's own +# terminology, since Copilot is what calls these subagents; the wire names match # the aidr_message.agent_id/agent_name columns they land in. +# +# Conditional ARGUMENTS, not a conditional value: `-H "x-rogue-agent-id: "` and +# `-H "x-rogue-agent-id:"` mean an empty value and suppress-this-header to curl, +# and neither is "do not send it". EVENT was captured at the top of the file, so +# `set --` is free to rebuild the positional list here. +set -- -H "x-rogue-api-key: $ROGUE_API_KEY" \ + -H "x-rogue-event: $EVENT" \ + -H "x-rogue-agent: $ROGUE_INSTALL_AGENT" \ + -H "x-rogue-host: $ROGUE_INSTALL_HOST" \ + -H "x-rogue-version: $ROGUE_INSTALL_VERSION" \ + -H "x-rogue-actor-email: $ROGUE_ACTOR_EMAIL" \ + -H "x-rogue-actor-name: $ROGUE_ACTOR_NAME" +# The id is a bare token from Copilot (toolu_… / call_…). Anything outside the +# token charset is not one — skip BOTH headers rather than emit a junk value. +case "$SUBAGENT_ID" in + ''|*[!A-Za-z0-9_-]*) : ;; + *) + set -- "$@" -H "x-rogue-agent-id: $SUBAGENT_ID" + [ -n "$SUBAGENT_NAME_B64" ] && set -- "$@" -H "x-rogue-agent-name-b64: $SUBAGENT_NAME_B64" + ;; +esac + RAW=$(printf '%s' "$BODY" | curl -sS -X POST "$URL" \ - -H "x-rogue-api-key: $ROGUE_API_KEY" \ - -H "x-rogue-event: $EVENT" \ - -H "x-rogue-agent: $ROGUE_INSTALL_AGENT" \ - -H "x-rogue-host: $ROGUE_INSTALL_HOST" \ - -H "x-rogue-version: $ROGUE_INSTALL_VERSION" \ - -H "x-rogue-actor-email: $ROGUE_ACTOR_EMAIL" \ - -H "x-rogue-actor-name: $ROGUE_ACTOR_NAME" \ + "$@" \ -H 'Content-Type: application/json' \ --data-binary @- --max-time 15 -w '\n%{http_code}') RC=$? diff --git a/tests/test_hook_ps1_copilot.ps1 b/tests/test_hook_ps1_copilot.ps1 index 1916d3f..0e2435a 100644 --- a/tests/test_hook_ps1_copilot.ps1 +++ b/tests/test_hook_ps1_copilot.ps1 @@ -7,7 +7,7 @@ # cross-bridge round-trip of ~/.rogue-env. This one covers the Copilot-only # JetBrains silent-block alert — the single out-of-band exception to pure relay — # and must stay in lockstep with tests/test_hook_sh_copilot.sh cases 4b-4e, plus -# the subagent body tag (Add-AgentTag), in lockstep with that file's cases 14-16. +# the subagent agent-tag HEADERS, in lockstep with that file's cases 14-16. # # These are the ONLY automated checks that ever execute hook.ps1's alert code: # a parse or logic error there is not a graceful degradation, because the @@ -174,65 +174,69 @@ $env:COPILOT_CLI_BINARY_VERSION = '1.0.75' Assert-True (-not (Test-JetBrainsIde)) 'fallback: version SET + no markers => not IDE' Clear-AlertEnv -# ── Add-AgentTag: the subagent body tag ──────────────────────────────────── -# A re-attributed subagent event gets agentId + agentNameB64 added to the POST -# body (the tag used to ride as x-rogue-agent-* headers). Mirrors hook.sh's -# augment_with_agent_tag and tests/test_hook_sh_copilot.sh cases 14-16 — the two -# dispatchers must emit the SAME bytes, so the expected literals here are the same -# ones asserted there. -$BODY = '{"sessionId":"p1","toolName":"bash"}' - -Assert-Eq (Add-AgentTag $BODY 'call_A' 'Task Agent') ` - '{"sessionId":"p1","toolName":"bash","agentId":"call_A","agentNameB64":"VGFzayBBZ2VudA=="}' ` - 'tag adds agentId + base64 display name' - -# The point of base64: a display name is arbitrary vendor text, and a raw '"' or -# '\' concatenated into the body would corrupt the JSON. Mirrors sh case 14b. -Assert-Eq (Add-AgentTag $BODY 'call_NASTYNAME' ('Task "Agent" ' + $BS + ' v2')) ` - '{"sessionId":"p1","toolName":"bash","agentId":"call_NASTYNAME","agentNameB64":"VGFzayAiQWdlbnQiIFwgdjI="}' ` - 'a name with " and \ is base64-encoded, not concatenated raw' - -# An unknown name omits the field entirely rather than shipping an empty string. -Assert-Eq (Add-AgentTag $BODY 'call_A' '') ` - '{"sessionId":"p1","toolName":"bash","agentId":"call_A"}' ` - 'empty display name omits agentNameB64' -Assert-Eq (Add-AgentTag $BODY 'call_A' $null) ` - '{"sessionId":"p1","toolName":"bash","agentId":"call_A"}' ` - 'null display name omits agentNameB64' - -# Fail-open: the id is a bare Copilot token, so anything outside [A-Za-z0-9_-] -# skips BOTH fields — losing attribution is fine, a corrupt body is not. -Assert-Eq (Add-AgentTag $BODY 'call_"evil' 'n') $BODY 'a quote in the id skips the tag' -Assert-Eq (Add-AgentTag $BODY ('call' + $BS + 'x') 'n') $BODY 'a backslash in the id skips the tag' -Assert-Eq (Add-AgentTag $BODY 'call A' 'n') $BODY 'a space in the id skips the tag' -Assert-Eq (Add-AgentTag $BODY '' 'n') $BODY 'an empty id skips the tag' -Assert-Eq (Add-AgentTag 'not json at all' 'call_A' 'n') 'not json at all' 'a non-object body is left alone' - -# Only ONE '}' is stripped (TrimEnd('}') would eat both and corrupt this body), -# and trailing whitespace is trimmed first so the strip lands on the real brace. -Assert-Eq (Add-AgentTag '{"a":{"b":1}}' 'call_A' $null) ` - '{"a":{"b":1},"agentId":"call_A"}' 'a body ending in "}}" keeps its nested object' -Assert-Eq (Add-AgentTag "{`"a`":1}`n" 'call_A' $null) ` - '{"a":1,"agentId":"call_A"}' 'trailing newline is trimmed before the brace strip' -# An empty object needs no comma separator. -Assert-Eq (Add-AgentTag '{}' 'call_A' $null) '{"agentId":"call_A"}' 'an empty object gets no stray comma' - -# ── Add-AgentTag: jq path == concat path ─────────────────────────────────── -# jq is used when it is on PATH (macOS 26 ships /usr/bin/jq) and the string concat -# otherwise. Only one runs on a given machine, so what keeps the untested path -# honest is that both emit the same bytes. Force the concat path by emptying PATH. -$prevPath = $env:PATH -try { - $env:PATH = '' - $concat = Add-AgentTag $BODY 'call_A' 'Task Agent' -} finally { $env:PATH = $prevPath } -Assert-Eq $concat '{"sessionId":"p1","toolName":"bash","agentId":"call_A","agentNameB64":"VGFzayBBZ2VudA=="}' ` - 'concat path (no jq on PATH) emits the documented bytes' -if (Get-Command jq -ErrorAction SilentlyContinue) { - Assert-Eq (Add-AgentTag $BODY 'call_A' 'Task Agent') $concat 'jq path and concat path are byte-identical' -} else { - Write-Host ' skip: jq not installed - jq path not exercised' -} +# ── The subagent agent tag rides in HEADERS ──────────────────────────────── +# A re-attributed subagent event is tagged with x-rogue-agent-id + +# x-rogue-agent-name-b64 (the same pair the Antigravity dispatcher sends) and the +# POSTed body carries only the sessionId rewrite. Mirrors hook.sh and +# tests/test_hook_sh_copilot.sh cases 14-16. +# +# The emit site lives in the dispatcher's MAIN body, which cannot run here (it +# stands down on non-Windows, and there is no stdin/server to drive it), so these +# are source-level assertions over hook.ps1 plus the value computations the two +# headers depend on. What they protect is the migration itself: any regrowth of +# the body tagger — the jq round-trip over arbitrary toolArgs — fails them. +$src = Get-Content -Raw -LiteralPath $hook + +Assert-True ($src -notmatch 'Add-AgentTag') 'Add-AgentTag is gone (definition and call site)' +Assert-True ($src -notmatch 'agentNameB64') 'no agentNameB64 body field remains' +Assert-True ($src -notmatch '"agentId":"') 'no agentId body field remains' +Assert-True ($src -notmatch '&\s+jq\b') 'no jq round-trip of the vendor payload remains' +# The one surviving body mutation on a subagent event (plus transcriptTailB64 on +# the two stop events, which is synthesised content and not a rewrite). +Assert-True ($src -match '\$payload\s+-replace\s+\(''"sessionId"') 'the sessionId rewrite is still there' + +$idKey = $src.IndexOf("'x-rogue-agent-id'") +$nameKey = $src.IndexOf("'x-rogue-agent-name-b64'") +$idGuard = $src.IndexOf('if ($subagentId -and') +$nameGuard = $src.IndexOf('if ($subagentName)') +Assert-True ($idKey -gt 0) 'x-rogue-agent-id is added to $headers' +Assert-True ($nameKey -gt 0) 'x-rogue-agent-name-b64 is added to $headers' +# Both keys are nested inside the id check, and the name inside its own check, so +# neither is ever sent empty on a main-agent event. +Assert-True ($idGuard -gt 0 -and $idGuard -lt $idKey) 'the id header is guarded by $subagentId' +Assert-True ($nameGuard -gt $idGuard -and $nameGuard -lt $nameKey) 'the name header is nested inside both checks' +Assert-True ($src -match "x-rogue-agent-name-b64'\]\s*=\s*(\r?\n\s*)?\[Convert\]::ToBase64String") ` + 'the name header value is base64, never raw vendor text' + +# The id charset gate moved from the deleted tagger to the emit site. Pull the +# pattern out of the source and hold it to the same truth table the body tagger +# had: a bare Copilot token passes, anything else skips BOTH headers. +$gate = [regex]::Match($src, "\`$subagentId -match '([^']+)'") +Assert-True ($gate.Success) 'the emit site still gates the id on a charset pattern' +$pat = $gate.Groups[1].Value +Assert-Eq $pat '^[A-Za-z0-9_-]+$' 'the gate is the bare Copilot token charset' +Assert-True ('toolu_bdrk_TESTSUB' -match $pat) 'a toolu_ id passes the gate' +Assert-True ('call_NASTYNAME' -match $pat) 'a call_ id passes the gate' +Assert-True (-not ('call_"evil' -match $pat)) 'a quote in the id fails the gate' +Assert-True (-not (('call' + $BS + 'x') -match $pat)) 'a backslash in the id fails the gate' +Assert-True (-not ('call A' -match $pat)) 'a space in the id fails the gate' +Assert-True (-not ('' -match $pat)) 'an empty id fails the gate' + +# The two dispatchers must agree on the header VALUES, so these are the exact +# base64 strings tests/test_hook_sh_copilot.sh decodes on the sh side. +function Get-NameB64 { param([string]$N) [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($N)) } +Assert-Eq (Get-NameB64 'Task Agent') 'VGFzayBBZ2VudA==' 'display name base64 matches the sh dispatcher' +Assert-Eq (Get-NameB64 ('Task "Agent" ' + $BS + ' v2')) 'VGFzayAiQWdlbnQiIFwgdjI=' ` + 'a name with " and \ round-trips through base64' +Assert-Eq (Get-NameB64 'Stop Agent') 'U3RvcCBBZ2VudA==' 'the stop-event display name matches the sh dispatcher' +# UTF-8 before base64, so a non-ASCII name cannot produce an invalid header value +# (HTTP header values are ISO-8859-1 by spec — the whole reason for the encoding). +# Built from codepoints, not a literal: Windows PowerShell 5.1 reads a BOM-less +# file as ANSI, so a non-ASCII literal here decodes to mojibake and can terminate +# the string early (it did - the 5.1 job failed to parse this file at all). The +# source stays pure ASCII; the VALUE under test is still non-ASCII. +$JP = -join @(0x30A8, 0x30FC, 0x30B8, 0x30A7, 0x30F3, 0x30C8 | ForEach-Object { [char]$_ }) +Assert-Eq (Get-NameB64 $JP) '44Ko44O844K444Kn44Oz44OI' 'a non-ASCII name is UTF-8 base64' if ($fails -gt 0) { Write-Host "" diff --git a/tests/test_hook_sh_copilot.sh b/tests/test_hook_sh_copilot.sh index 880e2dc..bcdb707 100755 --- a/tests/test_hook_sh_copilot.sh +++ b/tests/test_hook_sh_copilot.sh @@ -26,8 +26,6 @@ ENV_FILE="$(mktemp)" OUT_FILE="$(mktemp)" # Optional directory prepended to the dispatcher's PATH (see make_ps_shim). TEST_BIN="" -# 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 @@ -66,7 +64,7 @@ run_dispatcher() { ROGUE_FLUSH_WAIT_ITERS="${ROGUE_FLUSH_WAIT_ITERS:-}" \ ROGUE_COPILOT_STATE_DIR="${ROGUE_COPILOT_STATE_DIR:-}" \ ROGUE_SUBAGENT_RESOLVE_ITERS="${ROGUE_SUBAGENT_RESOLVE_ITERS:-}" \ - PATH="${TEST_BIN:+$TEST_BIN:}${TEST_PATH:-$PATH}" \ + PATH="${TEST_BIN:+$TEST_BIN:}$PATH" \ "$SH" "$HOOK" "$1" <<< "$2" > "$OUT_FILE" rc=$? set -e @@ -95,26 +93,6 @@ EOF printf '%s' "$d" } -# 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. -# 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 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" @@ -146,10 +124,15 @@ assert_eq() { echo " ok: $3" } +# One inbound header of the last POST ('' when absent). mock_server.py records +# them lowercased, which is what curl sends anyway. +header_value() { + python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["headers"].get(sys.argv[2], ""))' "$HEADERS_FILE" "$1" +} + 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" + local key="$1" expected="$2" label="$3" + assert_eq "$(header_value "$key")" "$expected" "$label" } assert_no_header() { @@ -489,8 +472,8 @@ rm -rf "$TDIR" # A subagent's own preToolUse arrives with sessionId = the model tool-call id # (toolu_…/call_…). The dispatcher must resolve the parent from the parent # transcript's subagent.started line, rewrite the POST body's sessionId to the -# parent, and tag the BODY with agentId + agentNameB64 (base64 of the display -# name). The tag used to ride as x-rogue-agent-* headers — those must be GONE. +# parent, and send the tag as the x-rogue-agent-id / x-rogue-agent-name-b64 +# HEADERS (the same pair the Antigravity dispatcher emits) — never as body fields. SDIR="$(mktemp -d)" PARENT="11111111-2222-3333-4444-555555555555" mkdir -p "$SDIR/$PARENT" @@ -501,87 +484,66 @@ printf '%s\n' \ restart_mock '{}' export ROGUE_COPILOT_STATE_DIR="$SDIR" export ROGUE_SUBAGENT_RESOLVE_ITERS=3 -set +e; run_dispatcher preToolUse '{"sessionId":"toolu_bdrk_TESTSUB","toolName":"bash","toolArgs":{"command":"ls"}}'; LAST_RC=$?; set -e +# toolArgs is arbitrary tool input, so it carries the two values a JSON round-trip +# would rewrite: an integer wider than a double and a float that does not survive +# reformatting. This payload is what Case 14a below diffs against. +SUB_STDIN='{"sessionId":"toolu_bdrk_TESTSUB","toolName":"bash","toolArgs":{"command":"ls","id":12345678901234567890,"ratio":0.10}}' +set +e; run_dispatcher preToolUse "$SUB_STDIN"; LAST_RC=$?; set -e unset ROGUE_COPILOT_STATE_DIR ROGUE_SUBAGENT_RESOLVE_ITERS assert_eq "$LAST_RC" "0" "re-attributed subagent event exits 0" assert_eq "$(posted_field sessionId)" "$PARENT" "subagent event sessionId rewritten to the parent session" -assert_eq "$(posted_field agentId)" "toolu_bdrk_TESTSUB" "body agentId carries the real subagent id" -assert_eq "$(posted_field agentNameB64 | base64 -d)" "Task Agent" "body agentNameB64 decodes to the display name" -# The rest of the vendor payload must survive the mutation untouched. -assert_eq "$(posted_field toolName)" "bash" "tagged body keeps the vendor fields" -assert_no_header "x-rogue-agent-id" "x-rogue-agent-id header removed (tag moved into the body)" -assert_no_header "x-rogue-agent-name" "x-rogue-agent-name header removed (tag moved into the body)" +assert_header "x-rogue-agent-id" "toolu_bdrk_TESTSUB" "x-rogue-agent-id carries the real subagent id" +assert_eq "$(header_value x-rogue-agent-name-b64 | base64 -d)" "Task Agent" \ + "x-rogue-agent-name-b64 decodes to the display name" +# The tag must NOT also ride in the body: those fields are the legacy transport. +has=$(posted_body | python3 -c 'import json,sys; d=json.load(sys.stdin); print("agentId" in d or "agentNameB64" in d)') +assert_eq "$has" "False" "no agentId/agentNameB64 body fields (the tag is header-borne)" + +# ── Case 14a: the POSTed body differs from stdin ONLY in sessionId ────────── +# The whole point of the header migration: the re-attribution sed is the one and +# only edit, so the vendor's own bytes (whitespace, escaping, number formatting +# inside toolArgs) reach the backend untouched. A re-serializing tagger would +# rewrite 12345678901234567890 and 0.10 here and fail this byte comparison. +EXPECTED_BODY="$(printf '%s' "$SUB_STDIN" | sed "s/toolu_bdrk_TESTSUB/$PARENT/")" +assert_eq "$(posted_body)" "$EXPECTED_BODY" \ + "re-attributed subagent body differs from the vendor's stdin only in sessionId" rm -rf "$SDIR" -# ── Case 14b/14c: BOTH mutation paths, byte-identical ─────────────────────── -# The tag is added with jq when it is on PATH (macOS 26 ships /usr/bin/jq) and by -# string concat otherwise — only ONE of those ever runs on a given machine, so the -# only thing keeping the untested path honest is that both produce the SAME bytes. -# The display name here carries a '"' and a '\': the exact characters that would -# corrupt the payload if the name were concatenated raw, and the whole reason it -# travels base64-encoded. (It is seeded through the submap cache because the -# transcript scraper's "[^"]*" regex can never yield a quote.) +# ── Case 14b: a display name with " and \ survives as base64 ──────────────── +# A display name is arbitrary vendor text, and HTTP header values are ISO-8859-1 +# by spec — which is why the name is base64-encoded rather than sent raw. (It is +# seeded through the submap cache because the transcript scraper's "[^"]*" regex +# can never yield a quote.) NASTY_NAME='Task "Agent" \ v2' SUB_ID="call_NASTYNAME" SEED_VALUE="$(printf '%s\n%s' "$PARENT" "$NASTY_NAME")" restart_mock '{}' -if ! command -v jq >/dev/null 2>&1; then - echo "FAIL [Case 14b]: jq is not installed, so the jq mutation path cannot be" >&2 - echo " compared against the concat fallback. Install jq (macOS 26 ships" >&2 - echo " /usr/bin/jq; 'brew install jq' / 'apt-get install jq' otherwise)." >&2 - exit 1 -fi set +e SEED_SUBMAP_ID="$SUB_ID" SEED_SUBMAP_VALUE="$SEED_VALUE" \ run_dispatcher preToolUse "{\"sessionId\":\"$SUB_ID\",\"toolName\":\"bash\",\"toolArgs\":{\"command\":\"ls\"}}" LAST_RC=$?; set -e -assert_eq "$LAST_RC" "0" "jq-path tag exits 0" -BODY_JQ="$(posted_body)" -valid=$(printf '%s' "$BODY_JQ" | python3 -c 'import json,sys; json.load(sys.stdin); print("True")') -assert_eq "$valid" "True" "jq-path body is valid JSON" -assert_eq "$(posted_field agentId)" "$SUB_ID" "jq path sets agentId" -assert_eq "$(posted_field agentNameB64 | base64 -d)" "$NASTY_NAME" 'jq path round-trips a name with " and \' - -NOJQ="$(make_nojq_path)" -restart_mock '{}' -set +e -TEST_PATH="$NOJQ" SEED_SUBMAP_ID="$SUB_ID" SEED_SUBMAP_VALUE="$SEED_VALUE" \ - run_dispatcher preToolUse "{\"sessionId\":\"$SUB_ID\",\"toolName\":\"bash\",\"toolArgs\":{\"command\":\"ls\"}}" -LAST_RC=$?; set -e -rm -rf "$NOJQ" -assert_eq "$LAST_RC" "0" "concat-path tag exits 0 (jq hidden from PATH)" -BODY_NOJQ="$(posted_body)" -valid=$(printf '%s' "$BODY_NOJQ" | python3 -c 'import json,sys; json.load(sys.stdin); print("True")') -assert_eq "$valid" "True" "concat-path body is valid JSON" -assert_eq "$(posted_field agentNameB64 | base64 -d)" "$NASTY_NAME" 'concat path round-trips a name with " and \' -assert_eq "$BODY_NOJQ" "$BODY_JQ" "jq path and concat fallback emit byte-identical bodies" - -# A resolved parent with an UNKNOWN display name omits agentNameB64 entirely -# (rather than shipping an empty string) — on both paths. +assert_eq "$LAST_RC" "0" "subagent event with a quoted display name exits 0" +assert_header "x-rogue-agent-id" "$SUB_ID" "x-rogue-agent-id set for the seeded subagent" +assert_eq "$(header_value x-rogue-agent-name-b64 | base64 -d)" "$NASTY_NAME" \ + 'x-rogue-agent-name-b64 round-trips a name with " and \' +assert_eq "$(posted_body)" "{\"sessionId\":\"$PARENT\",\"toolName\":\"bash\",\"toolArgs\":{\"command\":\"ls\"}}" \ + "body carries only the sessionId rewrite, whatever the name contains" + +# ── Case 14c: an UNKNOWN display name omits the name header entirely ──────── +# Never sent empty: the id header alone still attributes the rows. restart_mock '{}' set +e SEED_SUBMAP_ID="$SUB_ID" SEED_SUBMAP_VALUE="$PARENT" \ run_dispatcher preToolUse "{\"sessionId\":\"$SUB_ID\",\"toolName\":\"bash\"}" LAST_RC=$?; set -e -assert_eq "$LAST_RC" "0" "nameless subagent tag exits 0" -BODY_JQ="$(posted_body)" -has=$(printf '%s' "$BODY_JQ" | python3 -c 'import json,sys; print("agentNameB64" in json.load(sys.stdin))') -assert_eq "$has" "False" "no agentNameB64 when the display name is unknown" -assert_eq "$(posted_field agentId)" "$SUB_ID" "agentId still set without a name" -NOJQ="$(make_nojq_path)" -restart_mock '{}' -set +e -TEST_PATH="$NOJQ" SEED_SUBMAP_ID="$SUB_ID" SEED_SUBMAP_VALUE="$PARENT" \ - run_dispatcher preToolUse "{\"sessionId\":\"$SUB_ID\",\"toolName\":\"bash\"}" -LAST_RC=$?; set -e -rm -rf "$NOJQ" -assert_eq "$LAST_RC" "0" "nameless subagent tag exits 0 (concat path)" -assert_eq "$(posted_body)" "$BODY_JQ" "nameless tag is byte-identical on both paths" +assert_eq "$LAST_RC" "0" "nameless subagent event exits 0" +assert_header "x-rogue-agent-id" "$SUB_ID" "x-rogue-agent-id still set without a name" +assert_no_header "x-rogue-agent-name-b64" "no x-rogue-agent-name-b64 when the display name is unknown" -# ── Case 14d: a subagent agentStop carries BOTH body mutations ────────────── -# The tag goes on before the transcript tail, so a re-attributed stop event ships -# agentId + agentNameB64 + transcriptTailB64 and is still valid JSON. +# ── Case 14d: a subagent agentStop carries the tag AND the tail ───────────── +# The headers are independent of the body enrichment, so a re-attributed stop +# event ships the two agent headers and a transcriptTailB64 body. SDIR="$(mktemp -d)" mkdir -p "$SDIR/$PARENT" printf '%s\n' \ @@ -597,9 +559,11 @@ run_dispatcher agentStop "$(printf '{"sessionId":"toolu_bdrk_STOPSUB","timestamp LAST_RC=$?; set -e unset ROGUE_COPILOT_STATE_DIR ROGUE_SUBAGENT_RESOLVE_ITERS assert_eq "$LAST_RC" "0" "re-attributed agentStop exits 0" -both=$(posted_body | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("agentId")=="toolu_bdrk_STOPSUB" and "transcriptTailB64" in d and d.get("sessionId")==sys.argv[1])' "$PARENT") -assert_eq "$both" "True" "re-attributed agentStop body is valid JSON with the tag AND the tail" -assert_eq "$(posted_field agentNameB64 | base64 -d)" "Stop Agent" "re-attributed agentStop carries the display name" +both=$(posted_body | python3 -c 'import json,sys; d=json.load(sys.stdin); print("agentId" not in d and "transcriptTailB64" in d and d.get("sessionId")==sys.argv[1])' "$PARENT") +assert_eq "$both" "True" "re-attributed agentStop body is valid JSON with the tail and no body tag" +assert_header "x-rogue-agent-id" "toolu_bdrk_STOPSUB" "re-attributed agentStop carries the id header" +assert_eq "$(header_value x-rogue-agent-name-b64 | base64 -d)" "Stop Agent" \ + "re-attributed agentStop carries the display name header" rm -rf "$SDIR" # ── Case 15: unresolvable subagent id → fail-open (orphaned, never worse) ──── @@ -616,18 +580,21 @@ unset ROGUE_COPILOT_STATE_DIR ROGUE_SUBAGENT_RESOLVE_ITERS assert_eq "$LAST_RC" "0" "unresolved subagent event exits 0" assert_eq "$(posted_body)" '{"sessionId":"call_UNKNOWNSUB","toolName":"bash","toolArgs":{"command":"ls"}}' \ "unresolved subagent event POSTs the body unchanged (fail-open, no tag)" -assert_no_header "x-rogue-agent-id" "no x-rogue-agent-id header when unresolved" +assert_no_header "x-rogue-agent-id" "no x-rogue-agent-id header when unresolved" +assert_no_header "x-rogue-agent-name-b64" "no x-rogue-agent-name-b64 header when unresolved" if [ "$ELAPSED" -le 3 ]; then echo " ok: bounded resolve wait honored (${ELAPSED}s)"; else echo "FAIL [Case 15]: waited ${ELAPSED}s (unbounded?)" >&2; exit 1; fi rm -rf "$SDIR" # ── Case 16: a main-agent (UUID) session is never tagged ──────────────────── # The tag exists only to repair a re-attributed subagent event; an ordinary event -# must stay a verbatim relay. +# must stay a verbatim relay with neither agent header present. restart_mock '{}' set +e; run_dispatcher preToolUse '{"sessionId":"11111111-2222-3333-4444-555555555555","toolName":"bash"}'; LAST_RC=$?; set -e assert_eq "$LAST_RC" "0" "main-agent event exits 0" assert_eq "$(posted_body)" '{"sessionId":"11111111-2222-3333-4444-555555555555","toolName":"bash"}' \ - "main-agent event body is untouched (no agentId/agentNameB64)" + "main-agent event body is untouched" +assert_no_header "x-rogue-agent-id" "no x-rogue-agent-id header on a main-agent event" +assert_no_header "x-rogue-agent-name-b64" "no x-rogue-agent-name-b64 header on a main-agent event" echo echo "All copilot hook.sh tests passed (SH=$SH)."