From c9fd96b539121ccd833d3ea9219f727f4e3d4131 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 11 May 2026 17:29:38 -0700 Subject: [PATCH] feat(messages): body-verify Phase 2 on cueapi_send_message (Mike directive 2026-05-11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity port of cueapi-cli #51/#52/#53 (Layer 3 force-file + Phase 2 auto-verify + body_received-is-dict isinstance fix) and cueapi-python #39/#40 (auto_verify kwarg + body_received-is-dict isinstance fix). The MCP `cueapi_send_message` tool now sends `X-CueAPI-Verify-Echo: true` by default, then compares the substrate-echoed `body_received` against the body the caller passed. On mismatch, the handler throws an MCP- visible error with a byte-level divergence index + message_id so callers can quickly locate caller-side body corruption (typically shell expansion of $(...) / `...` / ${VAR} before the MCP tool received the arg). Schema additions: - `auto_verify: optional` (default true) — opt-out for perf-sensitive flows. Set false to skip the header + the check. Handler additions: - When verify-on, set `X-CueAPI-Verify-Echo: true` extraHeader. - After response, extract `body_received` defensively: STRING per the spec-lock (cueapi/cueapi#798 + cueapi-core #88), but fall back to `body_received.body` if the substrate emits the older dict shape (the brief wire-shape window between cueapi-core #86 and #88). Matches the defensive isinstance pattern in cueapi-cli #53 and cueapi-python #40. - Throw on mismatch with: byte-divergence index + message_id + diagnostic pointing to caller-side shell-expansion as the typical cause. Tests (8 new, in a dedicated describe block): - default-on sends the header - auto_verify=false opt-out omits the header - matching STRING shape passes silently - matching dict-shape (defensive fallback) passes silently - mismatch throws with body-verify-mismatch message - error includes byte-divergence index - error names message_id - missing body_received (substrate didn't echo) is silently OK (only present-but-mismatched fails — keeps the check forgiving on substrate changes that drop the echo entirely) Plus 2 existing tests updated (assert extraHeaders shape includes the new default `X-CueAPI-Verify-Echo: true`): - "sends `from` via X-Cueapi-From-Agent HEADER, NOT in the body" (×2) - "idempotency_key goes via Idempotency-Key HEADER, not body" (×2) Backlog row: cmp1v39ih. --- src/tools.ts | 64 +++++++++++++++- tests/tools.test.ts | 179 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 1 deletion(-) diff --git a/src/tools.ts b/src/tools.ts index 6dee70d..9bbb3b5 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -340,6 +340,12 @@ const sendMessageSchema = z.object({ .describe( "Optional ISO 8601 timestamp to schedule this message for future delivery (cueapi #623, §13). Omitted = send now (server default). When set in the future, the message sits in the recipient's inbox-query gate until `send_at <= now()`, then becomes fetchable; push-delivery dispatch is also gated. Past timestamps are treated as send-now (forgiving fallback). Same semantics as cue-fire `send_at` (PR #618)." ), + auto_verify: z + .boolean() + .optional() + .describe( + "Phase 2 body-verify defense-in-depth (Mike directive 2026-05-11). When true (default), the tool sends `X-CueAPI-Verify-Echo: true` on the POST and compares the substrate-echoed `body_received` against the body sent. On mismatch, the tool throws an error with byte-level diff details so callers can detect silent body corruption (e.g. caller-side shell expansion of $(...) / `...` / ${VAR} before reaching the MCP tool). Set to `false` to opt out (rare; perf-sensitive flows only — verify adds zero substrate roundtrips since the echo rides in the same POST response). Parity with cueapi-cli `--no-verify` opt-out (#52) and cueapi-python `auto_verify` kwarg (#39)." + ), }); // ---------- tools ---------- @@ -697,7 +703,15 @@ export const tools: ToolDefinition[] = [ if (args.idempotency_key) { extraHeaders["Idempotency-Key"] = args.idempotency_key; } - return client.request( + // Phase 2 body-verify defense (Mike directive 2026-05-11). Default + // verify-on; opt-out via auto_verify=false. The X-CueAPI-Verify-Echo + // header tells the substrate to echo `body_received` in the response + // so we can diff sent vs received. + const verify = args.auto_verify !== false; + if (verify) { + extraHeaders["X-CueAPI-Verify-Echo"] = "true"; + } + const resp = await client.request>( "POST", "/v1/messages", body, @@ -705,6 +719,54 @@ export const tools: ToolDefinition[] = [ undefined, extraHeaders ); + // Body-verify check. Wire shape per spec-lock (cueapi/cueapi#798, + // cueapi-core #88): substrate echoes `body_received` as a STRING. We + // also handle the dict shape defensively — that was the transient + // wire shape between #795 (initial Layer 1) and #798 (hotfix); + // could resurface in a future substrate rev. Matches the cueapi-cli + // and cueapi-python defensive isinstance pattern. + if (verify && resp && typeof resp === "object") { + const receivedRaw = (resp as Record).body_received; + let received: string | undefined; + if (typeof receivedRaw === "string") { + received = receivedRaw; + } else if ( + receivedRaw && + typeof receivedRaw === "object" && + typeof (receivedRaw as Record).body === "string" + ) { + received = (receivedRaw as Record).body; + } + if (received !== undefined && received !== args.body) { + // Mismatch — caller-side body corruption (likely shell expansion). + // Throw an MCP-visible error with byte-level diff so the caller + // can locate the divergence quickly. + const sentLen = args.body.length; + const recvLen = received.length; + let divergedAt = -1; + const common = Math.min(sentLen, recvLen); + for (let i = 0; i < common; i++) { + if (args.body[i] !== received[i]) { + divergedAt = i; + break; + } + } + if (divergedAt === -1 && sentLen !== recvLen) { + divergedAt = common; + } + const msgId = (resp as Record).id ?? ""; + throw new Error( + `cueapi_send_message body-verify mismatch (message_id=${String( + msgId + )}): sent ${sentLen} chars, substrate received ${recvLen} chars` + + (divergedAt >= 0 + ? `, first divergence at byte ${divergedAt}` + : "") + + `. This usually indicates caller-side shell expansion of $(...) / backticks / \${VAR} before reaching the MCP tool. Set auto_verify=false to opt out (rare; perf-sensitive flows only).` + ); + } + } + return resp; }, }, ]; diff --git a/tests/tools.test.ts b/tests/tools.test.ts index e81dbd5..f98e929 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -869,6 +869,9 @@ describe("cueapi_send_message — HTTP contract (PR #619 BCC-light)", () => { expect(calls[0].extraHeaders).toEqual({ "X-Cueapi-From-Agent": "agt_alice", + // Phase 2 body-verify is on by default — substrate echoes + // body_received in the response so we can diff sent vs received. + "X-CueAPI-Verify-Echo": "true", }); expect(calls[0].body).not.toHaveProperty("from"); }); @@ -941,6 +944,8 @@ describe("cueapi_send_message — HTTP contract (PR #619 BCC-light)", () => { expect(calls[0].extraHeaders).toEqual({ "X-Cueapi-From-Agent": "agt_alice", "Idempotency-Key": "user-action-123", + // Phase 2 body-verify is on by default. + "X-CueAPI-Verify-Echo": "true", }); expect(calls[0].body).not.toHaveProperty("idempotency_key"); }); @@ -1098,6 +1103,9 @@ describe("cueapi_send_message — HTTP contract (PR #619 BCC-light)", () => { expect(calls[0].extraHeaders).toEqual({ "X-Cueapi-From-Agent": "agt_alice", + // Phase 2 body-verify is on by default — substrate echoes + // body_received in the response so we can diff sent vs received. + "X-CueAPI-Verify-Echo": "true", }); expect(calls[0].body).not.toHaveProperty("from"); }); @@ -1170,6 +1178,8 @@ describe("cueapi_send_message — HTTP contract (PR #619 BCC-light)", () => { expect(calls[0].extraHeaders).toEqual({ "X-Cueapi-From-Agent": "agt_alice", "Idempotency-Key": "user-action-123", + // Phase 2 body-verify is on by default. + "X-CueAPI-Verify-Echo": "true", }); expect(calls[0].body).not.toHaveProperty("idempotency_key"); }); @@ -1399,6 +1409,175 @@ describe("cueapi_send_message — HTTP contract (PR #619 BCC-light)", () => { }); }); +describe("cueapi_send_message — body-verify Phase 2 (Mike directive 2026-05-11)", () => { + // Parity with cueapi-cli #51/#52/#53 + cueapi-python #39/#40. Substrate + // echoes `body_received` in the response when X-CueAPI-Verify-Echo: true + // is sent. We diff sent vs received and throw on mismatch with a + // byte-level diagnostic so caller-side body corruption (e.g. shell + // expansion of $(...) / `...` / ${VAR}) surfaces loudly instead of + // silently shipping garbage. + + function findTool(name: string) { + const t = tools.find((x) => x.name === name); + if (!t) throw new Error(`tool ${name} missing`); + return t; + } + + function stubClientWithResponse(response: Record) { + const calls: Array<{ + method: string; + path: string; + body?: unknown; + query?: unknown; + apiKey?: string; + extraHeaders?: Record; + }> = []; + const client = { + request: vi.fn( + async ( + method: string, + path: string, + body?: unknown, + query?: unknown, + apiKey?: string, + extraHeaders?: Record + ) => { + calls.push({ method, path, body, query, apiKey, extraHeaders }); + return response; + } + ), + } as unknown as CueAPIClient; + return { client, calls }; + } + + it("default verify-on sends X-CueAPI-Verify-Echo: true header", async () => { + const tool = findTool("cueapi_send_message"); + const { client, calls } = stubClientWithResponse({ + id: "msg_x", + body_received: "hello world", + }); + await tool.handler(client, { + to: "agt_bob", + from: "agt_alice", + subject: "x", + body: "hello world", + }); + expect(calls[0].extraHeaders?.["X-CueAPI-Verify-Echo"]).toBe("true"); + }); + + it("auto_verify=false opt-out omits X-CueAPI-Verify-Echo header", async () => { + const tool = findTool("cueapi_send_message"); + const { client, calls } = stubClientWithResponse({ id: "msg_x" }); + await tool.handler(client, { + to: "agt_bob", + from: "agt_alice", + subject: "x", + body: "hello", + auto_verify: false, + }); + expect(calls[0].extraHeaders).not.toHaveProperty("X-CueAPI-Verify-Echo"); + }); + + it("matching body_received passes through silently (STRING shape per #798 spec-lock)", async () => { + const tool = findTool("cueapi_send_message"); + const { client } = stubClientWithResponse({ + id: "msg_x", + body_received: "hello world", + body_received_sha256: "", + }); + const result = await tool.handler(client, { + to: "agt_bob", + from: "agt_alice", + subject: "x", + body: "hello world", + }); + // Returns the response verbatim — no throw, no mutation. + expect(result).toMatchObject({ id: "msg_x" }); + }); + + it("matching body_received as dict shape (defensive fallback for pre-#798 substrate) passes", async () => { + // The substrate emitted body_received as a parsed dict between #795 + // (Layer 1 ship) and #798 (STRING-shape hotfix). If a future substrate + // rev resurfaces dict shape, the defensive isinstance check still + // extracts `.body` for comparison. Pin the fallback path. + const tool = findTool("cueapi_send_message"); + const { client } = stubClientWithResponse({ + id: "msg_x", + body_received: { to: "agt_bob", subject: "x", body: "hello world" }, + }); + const result = await tool.handler(client, { + to: "agt_bob", + from: "agt_alice", + subject: "x", + body: "hello world", + }); + expect(result).toMatchObject({ id: "msg_x" }); + }); + + it("mismatched body_received throws with byte-divergence diagnostic", async () => { + const tool = findTool("cueapi_send_message"); + const { client } = stubClientWithResponse({ + id: "msg_x", + body_received: "hello WORLD", // diverges at byte 6 + }); + await expect( + tool.handler(client, { + to: "agt_bob", + from: "agt_alice", + subject: "x", + body: "hello world", + }) + ).rejects.toThrow(/body-verify mismatch/); + }); + + it("mismatch error includes the divergent-byte index", async () => { + const tool = findTool("cueapi_send_message"); + const { client } = stubClientWithResponse({ + id: "msg_x", + body_received: "hellz world", // diverges at byte 4 + }); + await expect( + tool.handler(client, { + to: "agt_bob", + from: "agt_alice", + subject: "x", + body: "hello world", + }) + ).rejects.toThrow(/byte 4/); + }); + + it("mismatch error message names the message_id from the response", async () => { + const tool = findTool("cueapi_send_message"); + const { client } = stubClientWithResponse({ + id: "msg_corrupted_123", + body_received: "not what we sent", + }); + await expect( + tool.handler(client, { + to: "agt_bob", + from: "agt_alice", + subject: "x", + body: "hello", + }) + ).rejects.toThrow(/msg_corrupted_123/); + }); + + it("missing body_received in response is silently OK (substrate didn't echo)", async () => { + // Server may not echo for various reasons (rate-limit, future substrate + // change). Verify-on should not fail if the field is absent — only fail + // on present-but-mismatched. + const tool = findTool("cueapi_send_message"); + const { client } = stubClientWithResponse({ id: "msg_x" }); + const result = await tool.handler(client, { + to: "agt_bob", + from: "agt_alice", + subject: "x", + body: "hello", + }); + expect(result).toMatchObject({ id: "msg_x" }); + }); +}); + describe("cueapi_bulk_delete_cues — schema + HTTP contract", () => { it("registers the tool with the cueapi_* naming convention", () => { const tool = tools.find((t) => t.name === "cueapi_bulk_delete_cues");