Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------
Expand Down Expand Up @@ -697,14 +703,70 @@ 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<Record<string, unknown>>(
"POST",
"/v1/messages",
body,
undefined,
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<string, unknown>).body_received;
let received: string | undefined;
if (typeof receivedRaw === "string") {
received = receivedRaw;
} else if (
receivedRaw &&
typeof receivedRaw === "object" &&
typeof (receivedRaw as Record<string, unknown>).body === "string"
) {
received = (receivedRaw as Record<string, string>).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<string, unknown>).id ?? "<unknown>";
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;
},
},
];
179 changes: 179 additions & 0 deletions tests/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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<string, unknown>) {
const calls: Array<{
method: string;
path: string;
body?: unknown;
query?: unknown;
apiKey?: string;
extraHeaders?: Record<string, string>;
}> = [];
const client = {
request: vi.fn(
async (
method: string,
path: string,
body?: unknown,
query?: unknown,
apiKey?: string,
extraHeaders?: Record<string, string>
) => {
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: "<irrelevant>",
});
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");
Expand Down