From a2e7832813c0c78ce516fbf03e480cd1260e72ac Mon Sep 17 00:00:00 2001 From: Yuval Date: Wed, 12 Aug 2026 20:11:29 +0300 Subject: [PATCH 1/3] feat(copilot): send the subagent agent tag as headers, not body fields A Copilot subagent's hook events carry no agent id, so the dispatcher resolves the parent from ~/.copilot/session-state//events.jsonl and injects the tag itself. It did that with jq -c '. + {agentId:$id,agentNameB64:$nb64}' which re-serializes the entire vendor payload: whitespace compacted, strings re-escaped, numbers reformatted through jq's double, all over toolArgs, which is arbitrary tool input. jq ships with macOS 26, so that was the common path and not a fallback, and it was the only place an otherwise byte-for-byte body got rewritten. The tag now rides as x-rogue-agent-id + x-rogue-agent-name-b64, the pair plugins/antigravity already emits and the backend's shared readAgentTagHeaders already reads (FIRE-1896, qualifire#1935). Deleted augment_with_agent_tag (hook.sh) and Add-AgentTag (hook.ps1) with its OutputEncoding dance, plus the jq-vs-concat byte-identity test that only existed to police the duality. hook.sh builds curl's argument list with set -- so the headers are conditional ARGUMENTS: -H "x-rogue-agent-id: " and -H "x-rogue-agent-id:" mean empty-value and suppress-header to curl, and neither is "do not send it". The ^[A-Za-z0-9_-]+$ id gate and the base64 name encoding move to the emit site unchanged. Left alone: the sessionId re-attribution rewrite (an anchored sed on a validated token, and event identity rather than attribution), transcriptTailB64, the flush wait, the submap cache, every fail-open rule, and the plugin version. Backend compatibility: it reads headers first and keeps the legacy agentId/agentNameB64 body fields as a permanent fallback, so installed plugins keep working indefinitely. Tests: the sh suite asserts the two headers instead of the body fields, that both are absent on a main-agent and on an unresolved event, and the new invariant this migration is for: on a re-attributed subagent preToolUse the POSTed body differs from the vendor's stdin ONLY in sessionId (the payload carries a >64-bit integer and a trailing-zero float that a JSON round-trip would rewrite). The ps1 suite mirrors it at source level, since the emit site sits in the dispatcher's main body, which stands down off Windows. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 6 +- plugins/copilot/scripts/hook.ps1 | 115 +++++----------------- plugins/copilot/scripts/hook.sh | 127 +++++++++---------------- tests/test_hook_ps1_copilot.ps1 | 119 ++++++++++++----------- tests/test_hook_sh_copilot.sh | 157 ++++++++++++------------------- 5 files changed, 192 insertions(+), 332 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4aa8062..c7f7827 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Mirrors the Claude plugin with deliberate differences: ### Cursor plugin (`plugins/cursor/`) A near-verbatim port of `qualifire-dev/rogue-plugin-cursor`'s `plugins/rogue/` (keep it in sync — re-pull on upstream changes). Mirrors the Claude/Codex dual-dispatcher with Cursor-native wiring: - **Dual dispatcher (sh + PowerShell), relay + ONE enrichment.** Each of the 18 Cursor events registers two `hooks.json` entries — `sh ./scripts/hook.sh ` (cwd-relative; Cursor runs hooks from the plugin root) and a PowerShell entry that loads `scripts/hook.ps1` via `$env:CURSOR_PLUGIN_ROOT`. Exactly one runs per machine (same arbitration as Claude). Endpoint `/api/v1/hooks/cursor`, header `x-rogue-source: cursor`, env var `CURSOR_PLUGIN_ROOT`. Reuses the shared `~/.rogue-env`. `setup.sh` / `setup.ps1` write it. -- **File pre-image (`preToolUse` only).** The one thing the dispatchers add to the vendor payload: on `preToolUse` with `tool_name` Write/Edit and an absolute `file_path`, they read that file (still PRE-edit at that point) and append `"rogueFilePreImageB64"`, using the same jq-or-string-concat duality and fail-open rules as Copilot's `augment_with_agent_tag`. **Every file qualifies except recognized binary extensions** (`_is_binary_path` / `Test-RogueBinaryPath`: images, fonts, archives, media, compiled artifacts, office documents, databases), whose base64 is pure payload with no text to compare; an unknown extension counts as text. Budget for it: on a file write this roughly doubles the request body, since the payload already carries the post-edit content. Cursor's `preToolUse` carries the full post-edit content and no pre-edit state, so the payload alone cannot say what the edit changed. **A missing file yields an EMPTY pre-image, and that is the create signal** — no Cursor payload field distinguishes a create from an overwrite (`old_string` is `""` for any pure insertion). **Over ~256 KB it sends NO pre-image**, never a truncated one: a partial pre-image misrepresents the file's pre-edit state instead of admitting we don't know it. Multi-hunk edits need no special handling: Cursor emits one full cycle per hunk, so the file on disk is already the correct per-hunk baseline. Field extraction prefers `jq` (it understands nesting and unescaping; `file_path` sits under `tool_input`) and falls back to a text scan only when jq is absent. +- **File pre-image (`preToolUse` only).** The one thing the dispatchers add to the vendor payload: on `preToolUse` with `tool_name` Write/Edit and an absolute `file_path`, they read that file (still PRE-edit at that point) and append `"rogueFilePreImageB64"`, using the same jq-or-string-concat duality and fail-open rules as Copilot's `augment_with_transcript`. **Every file qualifies except recognized binary extensions** (`_is_binary_path` / `Test-RogueBinaryPath`: images, fonts, archives, media, compiled artifacts, office documents, databases), whose base64 is pure payload with no text to compare; an unknown extension counts as text. Budget for it: on a file write this roughly doubles the request body, since the payload already carries the post-edit content. Cursor's `preToolUse` carries the full post-edit content and no pre-edit state, so the payload alone cannot say what the edit changed. **A missing file yields an EMPTY pre-image, and that is the create signal** — no Cursor payload field distinguishes a create from an overwrite (`old_string` is `""` for any pure insertion). **Over ~256 KB it sends NO pre-image**, never a truncated one: a partial pre-image misrepresents the file's pre-edit state instead of admitting we don't know it. Multi-hunk edits need no special handling: Cursor emits one full cycle per hunk, so the file on disk is already the correct per-hunk baseline. Field extraction prefers `jq` (it understands nesting and unescaping; `file_path` sits under `tool_input`) and falls back to a text scan only when jq is absent. - **Manifest is `.cursor-plugin/plugin.json`** (version is source of truth); the Cursor marketplace file is the repo-root `.cursor-plugin/marketplace.json` (source `./plugins/cursor`, plugin version must match plugin.json — enforced by `.github/workflows/validate.yml`), kept separate from `.claude-plugin/` and `.agents/plugins/`. - **No `auto-update.sh`.** The Cursor **Team Marketplace** (admin imports the repo via Dashboard) IS Cursor's native managed/auto-update path — we don't ship a script. Per-developer one-liner installs upgrade by re-running the installer. - **`commands/{setup,status}.md`**, not `skills/` — Cursor's slash-command format. @@ -53,11 +53,11 @@ A native **GitHub Copilot CLI plugin** (endpoint `/hooks/copilot`, family `copil - **Manifest is `plugin.json` at the plugin root** (Copilot-native; version is source of truth). Copilot marketplace file is the repo-root **`.github/plugin/marketplace.json`** (name `rogue-copilot`, source `./plugins/copilot`, version synced by `validate.yml`). It uses a **distinct marketplace name** because Copilot reads BOTH `.github/plugin/marketplace.json` AND `.claude-plugin/marketplace.json` — so `copilot plugin install rogue@rogue-copilot` must disambiguate from the Claude `rogue` plugin. **Hooks live in `hooks.json` at the plugin root** (`plugin.json` `"hooks":"hooks.json"`). - **Native per-OS dispatch, so NO Claude-style arbitration.** Copilot runs the `bash` key on macOS/Linux and the `powershell` key on Windows — exactly one per platform. There is **no** exactly-one-runs polyglot, no dollar-free PowerShell, no Git-Bash `uname` stand-down. Each `hooks.json` entry has a `bash` and a `powershell` command; **keep both byte-stable forever** (hook trust) and mutate only `scripts/*`. - **JetBrains runs TWO hook harnesses at once.** The IDE's own `HookExecutor` (Local agent) reads only `/.github/hooks/**/*.json`, supports 4 events, and **discards** hook decisions — and it *refuses* our plugin outright (`[HookExecutor] Refusing to execute hook from untrusted workspace folder: …/installed-plugins/rogue-copilot/rogue/hooks.json`), so the IDE's **Local** provider has zero Rogue coverage. **A plugin-side detector is impossible** — zero coverage means zero hook execution, and `/rogue:status` is unreachable there too (the Local provider does not load `~/.copilot/installed-plugins`); the only mitigation is documentation (`plugins/copilot/README.md`, `plugins/copilot/commands/setup.md`). We reach the IDE only via its **embedded CLI harness** (`copilot-language-server` runs the same engine: reads `~/.copilot/installed-plugins` + `~/.copilot/settings.json`, writes `~/.copilot/session-state/`, honors decisions). No IDE-side install exists or is needed. Gotchas: `settings.json` hooks are read **only at IDE startup**; the IDE fires a **subset** of events (`userPromptTransformed` 0/11 prompts, `permissionRequest` 0/8 despite 8 `permission.requested`) and emits `sessionEnd` **once per turn** (11× against 1 `sessionStart`). -- **`hook.sh`/`hook.ps1` are PURE RELAY** (like Codex/Cursor) — no block regex, no local modal, with **one narrow exception**: a `userPromptSubmitted` block inside JetBrains, which the IDE honors but renders NOWHERE (probed `reason`, `message`, `systemMessage`, `displayMessage`, `userMessage`, `additionalContext` — none surface; the chat just goes dead, so the user can't see why or learn the `rgx!` hint). Only there do the dispatchers fire a **modal** alert carrying the reason — `osascript` `display alert … as critical` / `notify-send -u critical` / PS `WScript.Shell.Popup`, i.e. the same mechanism the Claude plugin's deleted `security-alert.{sh,ps1}` used (recover them from git history rather than rewriting) — **minus its `tell application "System Events"` wrapper**, which is a cross-app Automation request: macOS attributes it to the host app and prompts *""PyCharm" wants access to control "System Events""* on the first block, i.e. a consent dialog in front of a security alert that a user can deny, permanently disabling it. A bare `display alert` needs no permission, and `activate` targets osascript itself (also permission-free) to bring it forward. **Always detached** — a modal waits for the click, so inline would stall the dispatcher. A non-modal banner was tried first and rejected: `osascript` posts it as *Script Editor*, with a "Show" button that opens Script Editor's iCloud folder. `ROGUE_IDE_ALERT=0` disables, `ROGUE_IDE_ALERT_DRYRUN=1` logs instead of alerting, `=2` also logs the fully-escaped literal (real newlines as `|`) so the escaping of server-controlled reason text is covered by a test. Note `additionalContext` is NOT an alternative — the IDE ignores it on `userPromptSubmitted` (it works on the CLI), so a hard block plus this alert is the only combination that both enforces and explains. The relayed body is never altered. Surface detection: both harnesses set `COPILOT_CLI=1`, so it keys off the **parent process** (`copilot-language-server` = IDE, `copilot` = terminal) with the env shape as fallback (IDE: `COPILOT_CLI_BINARY_VERSION` unset + `PKG_EXECPATH`/`GITHUB_COPILOT_RIPGREP_PATH_OVERRIDE` set). Two load-bearing details: the process-name pattern must match the **truncated** form `*copilot-langua*` — Linux caps `comm` at `TASK_COMM_LEN`-1 = 15 chars, so `copilot-language-server` reads as `copilot-languag` and a full-name-only pattern can NEVER hit there — and the bare `*copilot)` arm must stay **second**, because macOS BSD `ps -o comm=` prints the full executable path, which matches both. The block sniff must also be the strict `"decision"` : `"block"` PAIR (sh `grep`, ps `-match`), never a loose glob: the response is server-controlled and any sibling field valued `block` (a ruleset mode, a rule id) would pop a modal on an allowed prompt. Every other blocking event (`preToolUse` deny, `postToolUse`, `agentStop`) renders natively in BOTH surfaces — never alert for those. Delete this once JetBrains renders the reason itself, exactly as Claude's modal was removed once Claude Desktop did. **One further exception:** for `agentStop`/`subagentStop` the dispatcher enriches the POST body — it reads `transcriptPath` from the payload, `tail`s the last ~256 KB of the session `events.jsonl`, and appends it base64-encoded as `"transcriptTailB64"` (safe JSON concat — base64 has no special chars; fail-open leaves the body unchanged). The backend decodes it and extracts the turn's assistant messages (see **Backend attribution** below). The dispatcher makes **two other stdin mutations, both on a re-attributed subagent event** — the `sessionId` rewrite and the `agentId`/`agentNameB64` tag (see below); every remaining event is verbatim relay. **Flush race (load-bearing):** `agentStop` fires only AFTER the turn completes (Copilot writes the whole `assistant.message` line atomically at turn-end, THEN fires the hook — so the hook timestamp is post-stream and stream length is irrelevant), but ~5–50 ms BEFORE that line is flushed to disk in `events.jsonl`. A naive `tail` therefore captures a stale transcript missing the very reply — silently dropped. Both dispatchers call `wait_for_transcript_flush`/`Wait-TranscriptFlush` first: poll (bounded ~5 s — covers disk-flush lag, NOT streaming; `ROGUE_FLUSH_WAIT_ITERS` overrides for tests) until the last non-`hook.*` line is `assistant.turn_end` — appends are ordered, so once `turn_end` is on disk the final `assistant.message` before it is too. Timeout → fail-open (tail whatever is present). **Backend attribution:** `extractAssistantReplies` (copilot-hook-parser) returns **every** assistant message ≤ the event timestamp (ceiling), not just the last — because Copilot fires `agentStop` only when a turn ends WITHOUT a pending tool call, so a turn whose text preceded a tool call (e.g. "Running two parallel…" then a `task` call) never fires its own `agentStop`; the next one's overlapping tail is the only chance to record it. Main vs subagent is split by the transcript line's **`agentId`** (absent = main-agent → `agentStop`; present = subagent → `subagentStop`, tagged with the id + `subagent.started` display name). Each emitted message carries its transcript-line **ISO `timestamp`** (→ `occurred_at`, so it interleaves correctly with the tool calls) and the transcript-line **`id` as `sourceId`**; the backend dedups on `sourceId` (`getExistingMessageSourceIds` + `dedupeSourceIds`) so the overlapping tails persist each message exactly once — no freshness window needed. **Subagent re-attribution (the other two stdin mutations):** a subagent's own hook events arrive with `sessionId = toolu_…`/`call_…` and NO parent reference — persisted verbatim they orphan into a separate audit log. Both dispatchers resolve the parent by locating that id in `~/.copilot/session-state/*/events.jsonl` (a `subagent.started` line names it; the parent session id IS that transcript's directory name; `ROGUE_COPILOT_STATE_DIR` overrides for tests), cache the mapping (`~/.rogue/copilot-submap/`), rewrite the body's `sessionId` to the parent (still a `sed`/`-replace`), and **tag the BODY** — `"agentId":""` plus `"agentNameB64":""` (`augment_with_agent_tag` / `Add-AgentTag`; `parseGithubCopilot` reads both off the payload). It is a body field and not a header because the server needs it in exactly one place; base64 is what makes it safe, since a display name is arbitrary vendor text and one `"` or `\` concatenated raw would corrupt the payload (same trick as `transcriptTailB64`). The id is validated against `^[A-Za-z0-9_-]+$` first and BOTH fields are skipped if it doesn't match; `agentNameB64` is omitted when the name is unknown. **jq-or-concat duality (load-bearing):** each dispatcher adds the fields with **`jq` when it is on PATH** (macOS 26 ships `/usr/bin/jq`) and otherwise with the same string-concat trick as the tail (strip the trailing `}`, append, re-close) — jq is absent from older macOS and minimal Linux images, and `python3` is avoided plugin-wide (the `/usr/bin/python3` stub fails silently without Xcode CLT). Never `ConvertTo-Json`/full re-serialize in PowerShell. Since only ONE path ever runs on a given machine, `tests/test_hook_sh_copilot.sh` asserts the two produce **byte-identical** bodies on a compact payload (jq re-serializes, so a *pretty-printed* payload comes back compacted — semantically identical, and Copilot sends compact JSON). Tagging happens BEFORE the tail append, so a re-attributed stop ships `agentId`, `agentNameB64`, then `transcriptTailB64`. Bounded retry (~2 s, `ROGUE_SUBAGENT_RESOLVE_ITERS`) covers the flush race; unresolved, a bad id, or a jq failure → fail-open (body untouched, i.e. today's orphaned behavior — never worse). +- **`hook.sh`/`hook.ps1` are PURE RELAY** (like Codex/Cursor) — no block regex, no local modal, with **one narrow exception**: a `userPromptSubmitted` block inside JetBrains, which the IDE honors but renders NOWHERE (probed `reason`, `message`, `systemMessage`, `displayMessage`, `userMessage`, `additionalContext` — none surface; the chat just goes dead, so the user can't see why or learn the `rgx!` hint). Only there do the dispatchers fire a **modal** alert carrying the reason — `osascript` `display alert … as critical` / `notify-send -u critical` / PS `WScript.Shell.Popup`, i.e. the same mechanism the Claude plugin's deleted `security-alert.{sh,ps1}` used (recover them from git history rather than rewriting) — **minus its `tell application "System Events"` wrapper**, which is a cross-app Automation request: macOS attributes it to the host app and prompts *""PyCharm" wants access to control "System Events""* on the first block, i.e. a consent dialog in front of a security alert that a user can deny, permanently disabling it. A bare `display alert` needs no permission, and `activate` targets osascript itself (also permission-free) to bring it forward. **Always detached** — a modal waits for the click, so inline would stall the dispatcher. A non-modal banner was tried first and rejected: `osascript` posts it as *Script Editor*, with a "Show" button that opens Script Editor's iCloud folder. `ROGUE_IDE_ALERT=0` disables, `ROGUE_IDE_ALERT_DRYRUN=1` logs instead of alerting, `=2` also logs the fully-escaped literal (real newlines as `|`) so the escaping of server-controlled reason text is covered by a test. Note `additionalContext` is NOT an alternative — the IDE ignores it on `userPromptSubmitted` (it works on the CLI), so a hard block plus this alert is the only combination that both enforces and explains. The relayed body is never altered. Surface detection: both harnesses set `COPILOT_CLI=1`, so it keys off the **parent process** (`copilot-language-server` = IDE, `copilot` = terminal) with the env shape as fallback (IDE: `COPILOT_CLI_BINARY_VERSION` unset + `PKG_EXECPATH`/`GITHUB_COPILOT_RIPGREP_PATH_OVERRIDE` set). Two load-bearing details: the process-name pattern must match the **truncated** form `*copilot-langua*` — Linux caps `comm` at `TASK_COMM_LEN`-1 = 15 chars, so `copilot-language-server` reads as `copilot-languag` and a full-name-only pattern can NEVER hit there — and the bare `*copilot)` arm must stay **second**, because macOS BSD `ps -o comm=` prints the full executable path, which matches both. The block sniff must also be the strict `"decision"` : `"block"` PAIR (sh `grep`, ps `-match`), never a loose glob: the response is server-controlled and any sibling field valued `block` (a ruleset mode, a rule id) would pop a modal on an allowed prompt. Every other blocking event (`preToolUse` deny, `postToolUse`, `agentStop`) renders natively in BOTH surfaces — never alert for those. Delete this once JetBrains renders the reason itself, exactly as Claude's modal was removed once Claude Desktop did. **One further exception:** for `agentStop`/`subagentStop` the dispatcher enriches the POST body — it reads `transcriptPath` from the payload, `tail`s the last ~256 KB of the session `events.jsonl`, and appends it base64-encoded as `"transcriptTailB64"` (safe JSON concat — base64 has no special chars; fail-open leaves the body unchanged). The backend decodes it and extracts the turn's assistant messages (see **Backend attribution** below). The dispatcher makes **exactly one other stdin mutation, on a re-attributed subagent event**: the `sessionId` rewrite (see below). The subagent's agent tag is NOT one of them, it rides in headers; every remaining event is verbatim relay. **Flush race (load-bearing):** `agentStop` fires only AFTER the turn completes (Copilot writes the whole `assistant.message` line atomically at turn-end, THEN fires the hook — so the hook timestamp is post-stream and stream length is irrelevant), but ~5–50 ms BEFORE that line is flushed to disk in `events.jsonl`. A naive `tail` therefore captures a stale transcript missing the very reply — silently dropped. Both dispatchers call `wait_for_transcript_flush`/`Wait-TranscriptFlush` first: poll (bounded ~5 s — covers disk-flush lag, NOT streaming; `ROGUE_FLUSH_WAIT_ITERS` overrides for tests) until the last non-`hook.*` line is `assistant.turn_end` — appends are ordered, so once `turn_end` is on disk the final `assistant.message` before it is too. Timeout → fail-open (tail whatever is present). **Backend attribution:** `extractAssistantReplies` (copilot-hook-parser) returns **every** assistant message ≤ the event timestamp (ceiling), not just the last — because Copilot fires `agentStop` only when a turn ends WITHOUT a pending tool call, so a turn whose text preceded a tool call (e.g. "Running two parallel…" then a `task` call) never fires its own `agentStop`; the next one's overlapping tail is the only chance to record it. Main vs subagent is split by the transcript line's **`agentId`** (absent = main-agent → `agentStop`; present = subagent → `subagentStop`, tagged with the id + `subagent.started` display name). Each emitted message carries its transcript-line **ISO `timestamp`** (→ `occurred_at`, so it interleaves correctly with the tool calls) and the transcript-line **`id` as `sourceId`**; the backend dedups on `sourceId` (`getExistingMessageSourceIds` + `dedupeSourceIds`) so the overlapping tails persist each message exactly once — no freshness window needed. **Subagent re-attribution (the other stdin mutation, plus the agent-tag headers):** a subagent's own hook events arrive with `sessionId = toolu_…`/`call_…` and NO parent reference — persisted verbatim they orphan into a separate audit log. Both dispatchers resolve the parent by locating that id in `~/.copilot/session-state/*/events.jsonl` (a `subagent.started` line names it; the parent session id IS that transcript's directory name; `ROGUE_COPILOT_STATE_DIR` overrides for tests), cache the mapping (`~/.rogue/copilot-submap/`), rewrite the body's `sessionId` to the parent (still a `sed`/`-replace`), and **tag the request with two HEADERS**: `x-rogue-agent-id: ` plus `x-rogue-agent-name-b64: `, the same pair `plugins/antigravity` sends and the same pair the backend's shared `readAgentTagHeaders` expects. **Headers, not body fields (FIRE-1896).** The tag used to ride in the body as `agentId`/`agentNameB64`, added with a `jq -c '. + {…}'` edit that re-serialized the whole vendor payload (whitespace, string escaping, and number formatting over arbitrary `toolArgs`) on the path that jq-shipping macOS 26 makes the common one. In headers the POSTed event stays the vendor's own bytes, and the `sessionId` rewrite is the only remaining edit: an anchored substitution on a token we matched literally, with no parse and no re-serialize. The backend still reads the legacy body fields as a **permanent** fallback (`parseGithubCopilot`, header-first), so already-installed plugins keep working indefinitely; precedence is per-message transcript-derived `agentId` first, then the header, then the body field. base64 for the name 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. The id is validated against `^[A-Za-z0-9_-]+$` at the emit site and BOTH headers are skipped if it doesn't match; the name header is omitted (never sent empty) when the name is unknown, and neither header appears at all on a main-agent event. `hook.sh` builds curl's argument list with `set --` because `-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"; `hook.ps1` conditionally adds the two keys to its `$headers` hashtable. Bounded retry (~2 s, `ROGUE_SUBAGENT_RESOLVE_ITERS`) covers the flush race; unresolved or a bad id → fail-open (no headers, body untouched, i.e. today's orphaned behavior — never worse). - **FAIL-OPEN IS SAFETY-CRITICAL.** Copilot's **`preToolUse` is fail-CLOSED**: a non-zero hook exit (or exit 2) *denies* the tool. So the dispatchers **always `exit 0`** and emit `{}` on any error (never `set -e`; never let curl propagate non-zero), and every `hooks.json` command ends `; exit 0` as a belt-and-suspenders net. Timeouts are fail-open even for `preToolUse`; the HTTP client (15s) sits inside `timeoutSec` (30s). - **`hook.ps1` is loaded via `[scriptblock]::Create((Get-Content ...))`** (dodges ExecutionPolicy/GPO) and must stay **PowerShell 5.1-compatible** — Copilot prefers pwsh 7 but falls back to Windows PowerShell 5.1. It self-locates via the `${PLUGIN_ROOT}` arg (`$PSCommandPath` is empty for a scriptblock); `hook.sh` self-locates from `$0`. Its pure helpers (`Sanitize`, `Log`, `Test-JetBrainsIde`, `Show-BlockNotification`, `ConvertFrom-ShellQuoted`) sit **above** an `if ($env:ROGUE_PS_LIB_ONLY) { return }` seam — the same one Claude/Cursor use — so `tests/test_hook_ps1_copilot.ps1` can dot-source them on Linux without running the dispatcher. `.github/workflows/validate.yml` parses **every** `*.ps1` in the repo with `pwsh` and runs both PS test files: a scriptblock parse error is otherwise swallowed by the `hooks.json` `catch { '{}' }` loader into a permanent silent no-op, i.e. every Windows user loses all enforcement with no error. - **Events** (`hooks.json`): **all 14 Copilot hook events are registered and POSTed** (send-everything for audit + enforcement). All are sync via `hook.sh`/`hook.ps1` except the existing detached `sessionStart` heartbeat entry. **Enforced:** `preToolUse` (**deny** — tool calls incl. MCP, `matcher:".*"`), `postToolUse` (**replace** a flagged tool result via `modifiedResult` — MCP responses ride here), `agentStop` (**block** on the main agent's replies — ALL of the turn's main-agent messages, read from the transcript tail and deduped by `sourceId`), `subagentStop` (**block** on a subagent's messages — the transcript's `agentId`-tagged assistant messages, tagged with the subagent id/name), `userPromptTransformed` (**rewrite** a malicious prompt via `modifiedTransformedPrompt`). **`userPromptTransformed` is now emitted** by current Copilot (it wraps the raw prompt with ``/`` context); the parser keeps its `content` for evaluation but emits **no display message** — `userPromptSubmitted` already recorded the raw prompt, so a second (wrapped) user row would be a duplicate. **`task`/`read_agent` `postToolUse` results emit no tool message** either — the subagent output they carry is surfaced (tagged) via `subagentStop`, so recording it here would duplicate it "once as tool, once as assistant". **`userPromptSubmitted` CAN deny** — `{"decision":"block","reason":R}` blocks the turn **before the model call** (0 credits, no transcript row), on BOTH the terminal CLI (renders `! `) and JetBrains (renders nothing — see the alert exception above). This contradicts [GitHub's hooks reference](https://docs.github.com/en/copilot/reference/hooks-reference), which lists the event's output as "not processed"; probe-verified on CLI 1.0.75 (2026-07-28). `{"continue":false}` and `{"permissionDecision":"deny"}` are ignored there; `{"additionalContext":…}` IS injected. Because the behavior is undocumented it may regress, so the backend should ALSO keep emitting `modifiedTransformedPrompt` on `userPromptTransformed` as a documented fallback — the two are mutually exclusive (a blocked prompt never reaches the transform stage: 8 prompts → exactly 4 transforms, matching the 4 unblocked). `postToolUse` likewise honors an undocumented `{"decision":"block","reason":R}` that **withholds the result entirely** (UI shows `✗ Tool result blocked: `), which is cleaner than `modifiedResult` handing our warning to the model as if it were the tool's output. **Audit-only** (POSTed + persisted, never blocks): `sessionStart` (also fires the detached heartbeat + unconfigured hint), `sessionEnd`, `permissionRequest`, `postToolUseFailure`, `subagentStart`, `preCompact`, `errorOccurred`, `notification`. Register **`agentStop` (camelCase) only** — Copilot fires BOTH `agentStop` and `Stop` for a turn-end, so registering `Stop` too would double-POST (`tests/test_hooks_json_copilot.sh` asserts `Stop` is absent). **Subagent lifecycle facts:** a subagent's `userPromptSubmitted`/`preToolUse`/`postToolUse`/`agentStop` arrive with `sessionId = toolu_bdrk_…` (NOT a UUID) and the subagent's own `agentStop` has an **empty `transcriptPath`**; only `subagentStart`/`subagentStop` carry the main `sessionId` + `agentName`. -- **Native decision shapes** (emitted by the backend `formatCopilotResponse`, relayed verbatim): `preToolUse`/`permissionRequest` → `{"permissionDecision":"deny","permissionDecisionReason":R}`; `agentStop`/`subagentStop` → `{"decision":"block","reason":R}`; **`userPromptSubmitted` → `{"decision":"block","reason":R}`** — undocumented but probe-verified, and a **pinned contract**: both dispatchers’ JetBrains silent-block alert keys off exactly this shape (`hook.sh` `grep '"decision"[[:space:]]*:[[:space:]]*"block"'` / `hook.ps1` `-match '"decision"\s*:\s*"block"'`), so switching the key for this event silently stops the alert firing; `userPromptTransformed` → `{"modifiedTransformedPrompt":R}` (the documented **fallback** for the above, and mutually exclusive with it — a blocked prompt never reaches the transform stage); `postToolUse` → **preferred** `{"decision":"block","reason":R}` (withholds the tool result entirely — UI shows `✗ Tool result blocked: `), with `{"modifiedResult":{"resultType":"success","textResultForLlm":R}}` as the documented fallback (it hands our warning to the model as if it were the tool’s own output, so prefer the block) where **`R` = the standard block reason** (findings text + the `rgx!` override hint — the same text shown on a `preToolUse` deny, NOT a "[withheld]" wrapper); allow → `{}`. Headers: exactly four on **every** event — `x-rogue-api-key`, `x-rogue-event` (camelCase Copilot event name), `x-rogue-actor-email`, `x-rogue-actor-name`. The subagent tag is **NOT a header**: it rides in the body as `agentId`/`agentNameB64` (see subagent re-attribution above; `parseGithubCopilot` tags the canonical messages, and `enrichFromHeaders` deliberately has no dual-read path for it). **No `x-rogue-source`** (cursor-only) and **no `x-rogue-agent`** (codex-only). +- **Native decision shapes** (emitted by the backend `formatCopilotResponse`, relayed verbatim): `preToolUse`/`permissionRequest` → `{"permissionDecision":"deny","permissionDecisionReason":R}`; `agentStop`/`subagentStop` → `{"decision":"block","reason":R}`; **`userPromptSubmitted` → `{"decision":"block","reason":R}`** — undocumented but probe-verified, and a **pinned contract**: both dispatchers’ JetBrains silent-block alert keys off exactly this shape (`hook.sh` `grep '"decision"[[:space:]]*:[[:space:]]*"block"'` / `hook.ps1` `-match '"decision"\s*:\s*"block"'`), so switching the key for this event silently stops the alert firing; `userPromptTransformed` → `{"modifiedTransformedPrompt":R}` (the documented **fallback** for the above, and mutually exclusive with it — a blocked prompt never reaches the transform stage); `postToolUse` → **preferred** `{"decision":"block","reason":R}` (withholds the tool result entirely — UI shows `✗ Tool result blocked: `), with `{"modifiedResult":{"resultType":"success","textResultForLlm":R}}` as the documented fallback (it hands our warning to the model as if it were the tool’s own output, so prefer the block) where **`R` = the standard block reason** (findings text + the `rgx!` override hint — the same text shown on a `preToolUse` deny, NOT a "[withheld]" wrapper); allow → `{}`. Headers: four on **every** event — `x-rogue-api-key`, `x-rogue-event` (camelCase Copilot event name), `x-rogue-actor-email`, `x-rogue-actor-name` — plus, on a re-attributed subagent event only, the agent tag: `x-rogue-agent-id` and `x-rogue-agent-name-b64` (see subagent re-attribution above; the backend reads them through the shared `readAgentTagHeaders` and keeps the legacy `agentId`/`agentNameB64` body fields as a permanent fallback). **No `x-rogue-source`** (cursor-only) and **no `x-rogue-agent`** (codex-only, and it names the *surface*, not an agent identity: do not confuse it with `x-rogue-agent-id`). - **Credentials**: shared `~/.rogue-env` (mode 600), same precedence chain (`${PLUGIN_ROOT}/env` → `/etc/rogue/env` / `C:\ProgramData\rogue\env` → `~/.rogue-env`) and actor cascade (env → `git config --global` → hostname/whoami) as the other plugins. - **`commands/setup.md`** (user-invoked `/rogue:setup`, writes creds) + **`skills/status/SKILL.md`** (model-invocable `/rogue:status`, read-only) — mirroring Claude's disable-model-invocation split within Copilot's surfaces. **No `auto-update`** — Copilot has native `copilot plugin update`; monorepo installs upgrade by re-running the one-liner. `/setup` documents the one-time `/hooks` trust step (Copilot skips untrusted command hooks). diff --git a/plugins/copilot/scripts/hook.ps1 b/plugins/copilot/scripts/hook.ps1 index f51e3c9..cbca763 100644 --- a/plugins/copilot/scripts/hook.ps1 +++ b/plugins/copilot/scripts/hook.ps1 @@ -172,85 +172,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 } @@ -334,9 +257,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 @@ -400,10 +323,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" @@ -508,11 +427,27 @@ $headers = @{ 'x-rogue-actor-email' = $actorEmail 'x-rogue-actor-name' = $actorName } -# The subagent tag rides in the BODY (agentId/agentNameB64 — see Add-AgentTag), so -# every event POSTs the same four 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 four 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 24a696b..9dbbab9 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 @@ -221,11 +222,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. @@ -282,6 +284,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. @@ -289,70 +297,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 @@ -374,11 +318,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 @@ -386,16 +325,36 @@ esac # 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 four 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 four 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-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-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 0e7920d..0b4f07f 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,64 @@ $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). +Assert-Eq (Get-NameB64 'エージェント') '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 4d2b128..acdf2fe 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() { @@ -475,8 +458,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" @@ -487,87 +470,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' \ @@ -583,9 +545,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) ──── @@ -602,18 +566,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)." From 7bb8711456cf052b129ce694c10864feb431c03a Mon Sep 17 00:00:00 2001 From: Yuval Date: Tue, 1 Sep 2026 16:47:09 +0300 Subject: [PATCH 2/3] test(copilot): build the non-ASCII name from codepoints so 5.1 can parse the file main added a real Windows PowerShell 5.1 job, which reads a BOM-less file as ANSI. The Japanese literal decoded to mojibake that terminated the string early, so 5.1 failed to parse the whole test file and every case in it was skipped. Source is now pure ASCII; the value under test is still non-ASCII. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_hook_ps1_copilot.ps1 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_hook_ps1_copilot.ps1 b/tests/test_hook_ps1_copilot.ps1 index 0b4f07f..0e2435a 100644 --- a/tests/test_hook_ps1_copilot.ps1 +++ b/tests/test_hook_ps1_copilot.ps1 @@ -231,7 +231,12 @@ Assert-Eq (Get-NameB64 ('Task "Agent" ' + $BS + ' v2')) 'VGFzayAiQWdlbnQiIFwgdjI 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). -Assert-Eq (Get-NameB64 'エージェント') '44Ko44O844K444Kn44Oz44OI' 'a non-ASCII name is UTF-8 base64' +# 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 "" From c84a53356eec50281a510f9f9a185da4e17fdfe6 Mon Sep 17 00:00:00 2001 From: Yuval Date: Sun, 6 Sep 2026 15:43:31 +0300 Subject: [PATCH 3/3] chore(copilot): 1.2.3 -> 1.2.4 The subagent agent tag moves from the body to the headers in this branch, so installs in the field need a new version to pull it. Bumped in both the plugin manifest and the Copilot marketplace entry; scripts/plugin-versions.sh reads the former for the versions.json attached to the release, so no other file changes. Co-Authored-By: Claude Opus 5 (1M context) --- .github/plugin/marketplace.json | 4 ++-- plugins/copilot/plugin.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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",