diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 39eb8ea..a65d1b7 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -202,6 +202,14 @@ jobs: SH=dash sh tests/test_hook_logs.sh SH=bash bash tests/test_hook_logs.sh + - name: Gemini dispatcher contract (node) + # The only suite that exercises hook.mjs end to end against a real server, + # and until now the only dispatcher suite CI never ran — the file existed + # and nothing invoked it. It carries the subagent-attribution rules, whose + # failure mode is a WRONG x-rogue-agent-id on a main-agent tool row: a + # false attribution in an audit trail, which no other gate can see. + run: node --test tests/test_hook_mjs.mjs + - name: Log-shipper contract (sh) # The shipper is a byte-offset state machine over a file another process is # appending to and rotating underneath it, so its failure modes are silent by diff --git a/plugins/gemini/gemini-extension.json b/plugins/gemini/gemini-extension.json index d343cd3..91c4c64 100644 --- a/plugins/gemini/gemini-extension.json +++ b/plugins/gemini/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "rogue", - "version": "1.0.27", + "version": "1.0.28", "description": "Rogue Security AIDR — real-time AI agent detection and response for Gemini CLI", "contextFileName": "GEMINI.md" } diff --git a/plugins/gemini/scripts/hook.mjs b/plugins/gemini/scripts/hook.mjs index 547b02a..9c44641 100644 --- a/plugins/gemini/scripts/hook.mjs +++ b/plugins/gemini/scripts/hook.mjs @@ -7,6 +7,11 @@ // decision shapes ({"decision":"deny"|"block", "reason":...} / toolConfig), so // this dispatcher is a PURE RELAY — Gemini renders the block itself. // +// The POSTed body is BYTE-FOR-BYTE the bytes read from stdin. The dispatcher +// does parse those bytes (into a local, for subagent attribution — see +// resolveSubagentId), but the parse result never reaches the request: everything +// it derives travels as a HEADER. Never re-serialize the payload into `body`. +// // One cross-platform script replaces the sh + PowerShell dual-dispatcher used by // the Claude/Codex/Cursor plugins: Gemini CLI guarantees Node 20+ on PATH (every // install method requires it; Homebrew declares `node` as a dependency), so we @@ -193,6 +198,230 @@ function describeOutcome(bodyText) { return "outcome=allow"; } +// ── Subagent attribution (x-rogue-agent-id) ───────────────────────────────── +// +// A Gemini subagent's OWN hook events are shape-identical to the main agent's, +// and worse, they are shape-IDENTICAL in their identifying fields too: +// LocalAgentExecutor.executionContext (:347508-347510) hands the subagent the +// PARENT's Config and geminiClient and changes only promptId, so createBaseInput +// (:362493-362495) reads `session_id` off the parent's Config and +// `transcript_path` off the parent's recording service. A subagent's BeforeTool +// and the main agent's BeforeTool are byte-comparable. Nothing in the payload +// separates them, and nothing derived from it can. +// +// What the transcripts DO record is which delegations exist, on a bookkeeping +// rule rather than a timing one: +// +// a delegation appears in the subagent directory when it STARTS, +// and in the parent transcript when it ENDS, +// so (started minus finished) is what is running right now. +// +// dir = dirname(transcript_path) / sanitize(session_id) +// started = the .jsonl basenames in dir (each IS a subagent session UUID) +// finished = those recorded in the parent transcript +// agentId = the single remaining one, or nothing +// +// Both sides are append-only records upstream writes for its own reasons, so +// NO timestamp of any kind is read or compared: not file mtimes, not record +// timestamps. The answer is the same however long a hook takes and whatever the +// filesystem does with timestamp resolution. +// +// THAT SET IS NOT WHO FIRED THE EVENT. It is only which delegations are +// unfinished, and the main agent keeps running tools inside that window: +// +// 1. invoke_agent is parallelizable (_isParallelizable, :346515-346525, +// returns false only for edit tools, update_topic and an explicit +// wait_for_previous: true), and the scheduler dequeues a maximal run of +// parallelizable calls and executes them together (:346493). A batch of +// [invoke_agent, run_shell_command] runs the shell concurrently with the +// delegation, and the shell's AfterTool fires while the delegation is live. +// 2. The parent's completion record — the ONLY "finished" marker — is written +// once per MODEL RESPONSE, after the whole scheduler run resolves +// (:387589 then :387623), not once per batch. A response whose calls split +// into several batches (any edit tool forces a split) leaves the delegation +// "live" for every later batch in that response, BeforeTool included. +// +// So `live.length === 1` is not evidence that this event came from inside that +// subagent, and tagging an ordinary tool event on it hands a main-agent +// run_shell_command or replace the subagent's UUID. A wrong id is worse than a +// missing one — it is a false attribution in an audit trail — so ordinary tool +// events carry no tag at all. Recovering per-tool attribution needs a signal +// upstream does not currently emit; it cannot be inferred here. +// +// Bundle citations (Gemini CLI 0.58.0, @google/gemini-cli chunk-MFLFXOVQ.js): +// :285586-285603 ChatRecordingService.initialize — a subagent's transcript is +// `//.jsonl`, +// while the main session file sits directly in `/`. +// :347622 the subagent's sessionId IS its agentId (randomUUID), so the +// filename is a real per-instance id, not a slug two runs share. +// :331632 recordCompletedToolCalls stamps `agentId` on the parent's +// completed invoke_agent record. That record is "finished". +// :340820-340824 the AfterTool payload carries only llmContent/returnDisplay/ +// error — the tool response's `data.agentId` (:348714) is NOT +// relayed, which is why the id is resolved from disk at all. +const PARENT_MAX_BYTES = 32 * 1024 * 1024; // over this, send no header +const CANDIDATE_HEAD_BYTES = 64 * 1024; // enough for a subagent's first record + +// The one event the live set can legitimately name: `AfterTool invoke_agent`, +// the delegation's own completion. It is a delegation event by its tool_name, so +// no main-agent tool call can wear the tag, and the id it takes is the id of a +// delegation that is by construction unfinished at that moment — its own. +// +// The two-delegation case resolves conservatively rather than wrongly: a +// parallel batch of two invoke_agent calls leaves both live when either report +// fires, so `live.length !== 1` sends nothing rather than handing agent 1's id +// to agent 2's report. +// +// Every other event is excluded. Non-tool events (BeforeAgent, AfterAgent, +// BeforeModel, SessionStart, …) are never a subagent's, and BeforeAgent carries +// the developer's prompt, where a wrong tag is worst. Ordinary tool events are +// excluded because the live set does not identify who fired them — see the +// window analysis above. +function isAgentTaggableEvent(parsed) { + return EVENT === "AfterTool" && parsed.tool_name === "invoke_agent"; +} + +// Read a file as UTF-8, or null if it is missing, unreadable or over `maxBytes`. +function readCapped(file, maxBytes) { + try { + if (fs.statSync(file).size > maxBytes) return null; + return fs.readFileSync(file, "utf8"); + } catch { + return null; + } +} + +// The delegated prompts recorded in the parent, JSON-escaped for a raw-text +// substring test against a subagent file (see hasDelegatedPrompt). Only lines +// mentioning invoke_agent are parsed; the rest of the transcript is never JSON. +// +// ONLY records that lack an agentId contribute. A record that has one is already +// matched by the substring test on the id itself, so collecting its prompt adds +// no delegation to `finished` — it only widens what the prompt test matches, and +// a prompt is not unique to a delegation. Rerun the same prompt and the OLD +// completed record would mark the NEW live delegation finished, dropping the +// attribution of a delegation that is plainly running. A prompt collected here +// is instead the only trace an errored / cancelled / max-turns delegation left. +function delegatedPrompts(parentText) { + const out = []; + for (const line of parentText.split("\n")) { + if (!line.includes("invoke_agent")) continue; + let record; + try { + record = JSON.parse(line); + } catch { + continue; + } + for (const call of record?.toolCalls ?? []) { + if (call?.name !== "invoke_agent") continue; + if (typeof call?.agentId === "string" && call.agentId) continue; + const prompt = call?.args?.prompt; + if (typeof prompt === "string" && prompt) { + out.push(JSON.stringify(prompt).slice(1, -1)); + } + } + } + return out; +} + +// Fallback "finished" key for a delegation that ended WITHOUT an agentId: that +// value is read out of the tool RESPONSE, so an errored / cancelled / max-turns +// agent can be recorded without one, and it would otherwise stay "live" for the +// rest of the session and suppress every later delegation's report. `args` comes +// from the REQUEST and is always there, and the subagent's first `user` record +// embeds the delegated prompt verbatim inside its context preamble. +// +// Compared JSON-escaped against the raw head, so no parse of a possibly +// truncated head is needed and a multi-line prompt still matches. An unreadable +// head returns false, i.e. the candidate stays live and the usual +// unique-or-nothing guard applies. +// +// A prompt is not an identifier, so this cannot prove WHICH delegation it +// finished: rerun a prompt whose earlier delegation ended without an agentId and +// the live rerun matches it too. That direction is deliberate — it costs the +// rerun its header, where the opposite default would leave a dead delegation +// live and hand ITS id to the rerun's report. +function hasDelegatedPrompt(file, escapedPrompts) { + if (escapedPrompts.length === 0) return false; + let head = ""; + try { + const fd = fs.openSync(file, "r"); + try { + const buf = Buffer.alloc(CANDIDATE_HEAD_BYTES); + const n = fs.readSync(fd, buf, 0, CANDIDATE_HEAD_BYTES, 0); + head = buf.subarray(0, n).toString("utf8"); + } finally { + fs.closeSync(fd); + } + } catch { + return false; + } + return escapedPrompts.some((p) => head.includes(p)); +} + +// Returns the reporting delegation's session UUID, or undefined. NEVER throws: +// every failure path is "no header", because a wrong agent id is worse than a +// missing one and a throw here would cost the POST itself. +// +// Only reached for `AfterTool invoke_agent` — the delegation's own completion, +// which fires before the parent's record of it is written, so the delegation +// that is reporting is still one of the unfinished ones. See isAgentTaggableEvent. +function resolveSubagentId(parsed) { + try { + if (!parsed || typeof parsed !== "object") return undefined; + if (!isAgentTaggableEvent(parsed)) return undefined; + + const transcript = parsed.transcript_path; + const session = parsed.session_id; + if (typeof transcript !== "string" || !transcript) return undefined; + if (typeof session !== "string" || !session) return undefined; + + // Upstream's own sanitizer (:254005-254007); it also makes the joined path + // traversal-proof, since "/" and "." both become "_". + const dir = path.join( + path.dirname(transcript), + session.replace(/[^a-zA-Z0-9_-]/g, "_"), + ); + // No delegation in this session → ENOENT → no header, the common case. + const started = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => f.slice(0, -".jsonl".length)); + if (started.length === 0) return undefined; + + // Unreadable / absent (recording disabled, conversationFile nulled after + // ENOSPC) or oversized: `finished` is uncomputable and every candidate would + // look live, so send nothing. + const parent = readCapped(transcript, PARENT_MAX_BYTES); + if (parent === null) return undefined; + + // A subagent UUID appears in the parent on exactly one line, its completion + // record, so the primary key is a plain substring test that needs no JSON + // parsing at all. Everything recorded → nothing is running. + const unresolved = started.filter((id) => !parent.includes(id)); + if (unresolved.length === 0) return undefined; + + // Only what the substring test missed pays for the prompt fallback. + const prompts = delegatedPrompts(parent); + const live = unresolved.filter( + (id) => !hasDelegatedPrompt(path.join(dir, `${id}.jsonl`), prompts), + ); + + // Zero → the reporting delegation was already resolved by the prompt + // fallback. Two or more → a parallel batch of delegations, which the rule + // cannot separate. Both send nothing. + if (live.length !== 1) return undefined; + // The id is a filename, i.e. arbitrary bytes from disk. An invalid header + // value makes fetch THROW, which would fail-open the whole POST — far worse + // than no attribution. It must look like the vendor UUID it is, and the + // backend rejects (never truncates) an id over 64 chars. + const id = live[0]; + return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : undefined; + } catch { + return undefined; + } +} + // ── Read all of stdin ──────────────────────────────────────────────────────── function readStdin() { return new Promise((resolve) => { @@ -266,6 +495,17 @@ async function main() { // the fleet roster, which is worth seeing in the hook log. if (install.error) log(`error=install-id ${install.error}`); + // Inspect the relayed bytes in a LOCAL. The parse result is only ever read + // from — `body: payload` below stays the exact bytes Gemini piped in. + let parsed = null; + try { + parsed = JSON.parse(payload); + } catch { + parsed = null; + } + const agentId = resolveSubagentId(parsed); + const agentLog = `agent=${agentId || "none"}`; + let bodyText = "{}"; try { const resp = await fetch(url, { @@ -279,6 +519,11 @@ async function main() { "x-rogue-host": install.host, "x-rogue-version": install.version, "x-rogue-agent": install.agent, + // Only the id. The agent NAME is already inside the relayed bytes on the + // one event that has one (AfterTool invoke_agent's tool_input.agent_name) + // and the backend reads it from there, so a name header would duplicate a + // field for zero information. + ...(agentId ? { "x-rogue-agent-id": agentId } : {}), }, body: payload, signal: AbortSignal.timeout(15000), @@ -293,13 +538,13 @@ async function main() { bodyText = "{}"; } } - log(`http=${resp.status} ${describeOutcome(bodyText)}`); + log(`http=${resp.status} ${agentLog} ${describeOutcome(bodyText)}`); } else { - log(`http=${resp.status} outcome=fail-open`); + log(`http=${resp.status} ${agentLog} outcome=fail-open`); bodyText = "{}"; } } catch (e) { - log(`error="${sanitize(e && e.message)}" outcome=fail-open`); + log(`error="${sanitize(e && e.message)}" ${agentLog} outcome=fail-open`); bodyText = "{}"; } diff --git a/tests/test_hook_mjs.mjs b/tests/test_hook_mjs.mjs index 4a12a73..db125af 100644 --- a/tests/test_hook_mjs.mjs +++ b/tests/test_hook_mjs.mjs @@ -47,15 +47,30 @@ function runHook(event, payload, env) { } // Start a one-shot server that records the request and replies with `body`. +// `seen.raw` keeps the inbound bytes UNDECODED — the subagent-attribution tests +// assert the POSTed body is byte-identical to what was piped in on stdin. function startServer(status, body) { return new Promise((resolve) => { const seen = {}; const server = http.createServer((req, res) => { - seen.headers = req.headers; - let b = ""; - req.on("data", (c) => (b += c)); + // SessionStart and AfterAgent also spawn the DETACHED heartbeat (hook.mjs + // fireHeartbeat), which POSTs to /api/v1/hooks/status and, riding along + // inside it, the log shipper, which POSTs to /api/v1/hooks/logs. Both race + // the event POST on this same server. Answer them, but record ONLY the + // event endpoint, or an assertion reads a side channel's body instead. + // + // A whitelist, not a list of the side channels to skip: excluding + // /hooks/status alone left the shipper recording over the event, which is + // a byte-identity failure that only shows up under load. + const isEvent = (req.url || "").endsWith("/hooks/gemini"); + if (isEvent) seen.headers = req.headers; + const chunks = []; + req.on("data", (c) => chunks.push(c)); req.on("end", () => { - seen.body = b; + if (isEvent) { + seen.raw = Buffer.concat(chunks); + seen.body = seen.raw.toString("utf8"); + } res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); }); @@ -280,3 +295,340 @@ test("SessionEnd → POSTs with x-rogue-event SessionEnd", async () => { server.close(); } }); + +// ── Subagent attribution (x-rogue-agent-id) ───────────────────────────────── +// The dispatcher resolves the reporting delegation as (subagent files present) +// minus (delegations the parent transcript records as finished), and sends the +// single remaining session UUID as x-rogue-agent-id on `AfterTool invoke_agent` +// alone. These fixtures are literal transcript records copied in shape from a +// real Gemini session; NO timestamp, mtime or ordering is manipulated anywhere, +// because the rule reads none. +// +// The event restriction is the load-bearing correctness property here, so it is +// asserted from both sides: the delegation report gets the id, and every +// ordinary tool event gets nothing however live the delegation looks. + +const SESSION_ID = "d0fc529c-7537-40db-8302-ae175ef23655"; +const SUB_A = "f2401533-ab7c-4e7c-9a75-603c29d9e9c6"; +const SUB_B = "9db87efe-1f9e-4b8a-a41b-9367fb677095"; +const PROMPT_A = "How do I create a custom subagent?\nGive me the details."; +const PROMPT_B = "Write a poem about the sea."; + +// The parent's completed invoke_agent record. recordCompletedToolCalls stamps +// `agentId` with the subagent's session UUID; an abnormally terminated +// delegation is recorded WITHOUT it (agentId comes from the tool response). +function completionRecord(prompt, agentId) { + return JSON.stringify({ + id: "3cd90726-47e3-432a-ba34-bb9ccbbced71", + timestamp: "2026-08-13T09:25:14.818Z", + type: "gemini", + content: "", + toolCalls: [ + { + id: "invoke_agent__call_500699", + name: "invoke_agent", + args: { agent_name: "cli_help", prompt, wait_for_previous: true }, + status: "success", + ...(agentId ? { agentId } : {}), + }, + ], + }); +} + +// A subagent transcript: its header plus the first `user` record, which embeds +// the delegated prompt verbatim inside the context preamble. Nothing else in +// the file is ever read. +function subagentFile(uuid, prompt) { + return `${JSON.stringify({ + sessionId: uuid, + projectHash: "52b218b5", + startTime: "2026-08-13T09:27:02.187Z", + lastUpdated: "2026-08-13T09:27:02.187Z", + kind: "subagent", + directories: ["/tmp/project"], + })}\n${JSON.stringify({ + id: "e4da2b35-0de8-419f-acdb-d37187939a9f", + timestamp: "2026-08-13T09:27:05.698Z", + type: "user", + content: [{ text: `\n\n...\n\n${prompt}` }], + })}\n`; +} + +// Build chats/.jsonl + chats//.jsonl and return the +// transcript path. `subagents` is a list of [uuid, prompt] pairs. +function makeChats(parentRecords, subagents, { noSubDir = false } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rogue-gem-chats-")); + const chats = path.join(root, "chats"); + fs.mkdirSync(chats, { recursive: true }); + if (!noSubDir) { + fs.mkdirSync(path.join(chats, SESSION_ID)); + for (const [uuid, prompt] of subagents) { + fs.writeFileSync( + path.join(chats, SESSION_ID, `${uuid}.jsonl`), + subagentFile(uuid, prompt), + ); + } + } + const transcript = path.join(chats, "session-2026-08-13T09-24-d0fc529c.jsonl"); + fs.writeFileSync(transcript, parentRecords.map((r) => `${r}\n`).join("")); + return { root, transcript }; +} + +// POST one event and return the x-rogue-agent-id header (undefined = not sent). +// Asserts on every call that the body relayed is byte-identical to stdin. +async function agentIdFor(event, payload) { + const { server, seen, port } = await startServer(200, "{}"); + try { + await runHook(event, payload, { + ROGUE_API_KEY: "rsk_test", + ROGUE_BASE_URL: `http://127.0.0.1:${port}`, + }); + assert.deepEqual( + seen.raw, + Buffer.from(payload, "utf8"), + "the POSTed body must be the stdin bytes, unmodified", + ); + return seen.headers["x-rogue-agent-id"]; + } finally { + server.close(); + } +} + +// Standard tool-event payload: only the base fields Gemini actually sends. +function toolPayload(transcript, toolName) { + return JSON.stringify({ + session_id: SESSION_ID, + transcript_path: transcript, + cwd: "/tmp/project", + hook_event_name: "BeforeTool", + tool_name: toolName, + tool_input: { command: "ls" }, + }); +} + +test("the delegation report carries the live subagent's UUID", async () => { + const { root, transcript } = makeChats([], [[SUB_A, PROMPT_A]]); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), + SUB_A, + "AfterTool invoke_agent IS the delegation's own completion", + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("no ordinary tool event is tagged, however live the delegation looks", async () => { + // The live set says which delegations are unfinished, NOT who fired the event. + // The main agent keeps running tools inside that window: invoke_agent is + // parallelizable, so a batch of [invoke_agent, run_shell_command] runs the + // shell alongside it, and the parent's completion record — the only "finished" + // marker — is written once per model response, after the whole scheduler run, + // so every later batch in that response sees the delegation live too. Tagging + // any of those events attributes a MAIN-agent tool call to the subagent. + const { root, transcript } = makeChats([], [[SUB_A, PROMPT_A]]); + try { + for (const tool of ["run_shell_command", "read_file", "replace", "invoke_agent"]) { + assert.equal( + await agentIdFor("BeforeTool", toolPayload(transcript, tool)), + undefined, + `BeforeTool ${tool}`, + ); + } + for (const tool of ["run_shell_command", "read_file", "replace"]) { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, tool)), + undefined, + `AfterTool ${tool}`, + ); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("two live delegations → no header (concurrency is unattributable)", async () => { + const { root, transcript } = makeChats( + [], + [ + [SUB_A, PROMPT_A], + [SUB_B, PROMPT_B], + ], + ); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), + undefined, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("delegation recorded in the parent (agentId) → finished, no header", async () => { + const { root, transcript } = makeChats( + [completionRecord(PROMPT_A, SUB_A)], + [[SUB_A, PROMPT_A]], + ); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), + undefined, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("finished delegation + one live one → only the live UUID", async () => { + const { root, transcript } = makeChats( + [completionRecord(PROMPT_A, SUB_A)], + [ + [SUB_A, PROMPT_A], + [SUB_B, PROMPT_B], + ], + ); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), + SUB_B, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("rerunning a completed delegation's prompt keeps the rerun's own id", async () => { + // SUB_A finished normally, so its record carries an agentId and the substring + // test alone resolves it. SUB_B is a live rerun of the SAME prompt. Collecting + // prompts from records that already have an agentId would let SUB_A's record + // mark SUB_B finished and drop the header for a delegation that is running. + const { root, transcript } = makeChats( + [completionRecord(PROMPT_A, SUB_A)], + [ + [SUB_A, PROMPT_A], + [SUB_B, PROMPT_A], + ], + ); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), + SUB_B, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("abnormal termination (record without agentId) → prompt marks it finished", async () => { + // The delegation is recorded but carries no agentId (errored / cancelled / + // max-turns). Without the args.prompt fallback it would look live forever and + // suppress every later delegation's report. + const { root, transcript } = makeChats( + [completionRecord(PROMPT_A, null)], + [[SUB_A, PROMPT_A]], + ); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), + undefined, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("no subagent directory → no header", async () => { + const { root, transcript } = makeChats([], [], { noSubDir: true }); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), + undefined, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("parent transcript missing or oversized → no header", async () => { + const missing = makeChats([], [[SUB_A, PROMPT_A]]); + try { + fs.rmSync(missing.transcript); + assert.equal( + await agentIdFor("AfterTool", toolPayload(missing.transcript, "invoke_agent")), + undefined, + "unreadable parent → finished is uncomputable → no header", + ); + } finally { + fs.rmSync(missing.root, { recursive: true, force: true }); + } + + const big = makeChats([], [[SUB_A, PROMPT_A]]); + try { + // Sparse grow past the 32 MB cap; only statSync().size is consulted. + fs.truncateSync(big.transcript, 32 * 1024 * 1024 + 1); + assert.equal( + await agentIdFor("AfterTool", toolPayload(big.transcript, "invoke_agent")), + undefined, + ); + } finally { + fs.rmSync(big.root, { recursive: true, force: true }); + } +}); + +test("non-tool events never carry the tag", async () => { + const { root, transcript } = makeChats([], [[SUB_A, PROMPT_A]]); + const payload = JSON.stringify({ + session_id: SESSION_ID, + transcript_path: transcript, + cwd: "/tmp/project", + }); + try { + for (const event of ["SessionStart", "BeforeAgent", "AfterAgent", "BeforeModel"]) { + assert.equal(await agentIdFor(event, payload), undefined, event); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("malformed / minimal payloads resolve to no header, never a failed POST", async () => { + assert.equal(await agentIdFor("AfterTool", "not json at all"), undefined); + assert.equal(await agentIdFor("AfterTool", '{"tool_name":"invoke_agent"}'), undefined); + assert.equal( + await agentIdFor( + "AfterTool", + '{"tool_name":"invoke_agent","session_id":123,"transcript_path":null}', + ), + undefined, + ); +}); + +test("body stays byte-identical even when a header is added", async () => { + const { root, transcript } = makeChats([], [[SUB_A, PROMPT_A]]); + // Pretty-printed, non-ASCII, trailing newline: anything re-serialized would + // come back compacted and/or re-escaped. + const payload = `${JSON.stringify( + { + session_id: SESSION_ID, + transcript_path: transcript, + tool_name: "invoke_agent", + tool_input: { agent_name: "cli_help", prompt: "héllo — 世界 # \\u0041" }, + }, + null, + 2, + )}\n`; + const { server, seen, port } = await startServer(200, "{}"); + try { + await runHook("AfterTool", payload, { + ROGUE_API_KEY: "rsk_test", + ROGUE_BASE_URL: `http://127.0.0.1:${port}`, + }); + assert.equal(seen.headers["x-rogue-agent-id"], SUB_A); + assert.deepEqual(seen.raw, Buffer.from(payload, "utf8")); + } finally { + server.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +});