Skip to content

Commit 41c5b37

Browse files
mikemolinetclaude
andcommitted
messages.send: auto_verify body-echo (Phase 2 body-verify defense)
Mike body-verify directive 2026-05-11 — Layer 2 SDK auto-verify across cueapi-python. Sibling to Layer 3 force-file mode shipped in cueapi-cli #51 + cue-mac-app commit b892613. Bug class (caller-side shell expansion silently mutating body content BEFORE the SDK receives it): cueapi-cli #51 closed the CLI side via force-file mode. cueapi-python callers (notebook / script / hosted agent runtime) hit the same class via f-strings + os.popen + format() + similar Python-level mutation hidden under "literal string". Auto-verify catches it server-side via echo-back, raising BodyVerifyMismatchError instead of silent corruption. Changes: cueapi/exceptions.py: - BodyVerifyMismatchError(CueAPIError) — new exception with sent_body, received_body, first_divergence_byte, message_id attributes. - first_divergence_byte(a, b) — pure helper returning byte index of first differing position. -1 when one is a proper prefix of the other (length mismatch; caller distinguishes via len()). Cross-SDK re-usable. cueapi/resources/messages.py: - send(auto_verify=True) — new kwarg, default ON per CTO concur (Q-C4). Adds X-CueAPI-Verify-Echo: true header. After 201, checks response.get("body_received") against sent body; raises BodyVerifyMismatchError on mismatch. - Backward-compat: when substrate omits body_received field (pre- Layer-1 deploy), SDK silently no-ops. Existing callers see no behavior change until both Layer 1 + Layer 2 are deployed. - Opt-out: auto_verify=False omits header + skips check. tests/test_messages_resource.py: - 11 new tests pin: - Default auto_verify=True adds header - Opt-out omits header - Byte-identical response returns normally - Mismatched response raises BodyVerifyMismatchError with attributes - Substrate omits echo field → no raise (backward-compat) - Opt-out skips verify even if substrate echoes - first_divergence_byte: equal, prefix, first-char, middle, realistic metachar-substitution scenario - 4 existing tests updated to expect default-on header. - All 22 messages tests pass. Phase 2 substrate field name `body_received` documented as locked during joint CMA + cueapi-primary design (design Dock workspace cue-message-silent-corruption-substrate-design-2026-05-11). If Layer 1 ships under a different name, update _VERIFY_ECHO_FIELD constant (single 1-line change). CHANGELOG entry under [Unreleased]. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 72fb628 commit 41c5b37

4 files changed

Lines changed: 253 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to cueapi-sdk will be documented here.
66

77
### Added
88

9+
- **`client.messages.send(auto_verify=True)` body-verify defense (Mike directive 2026-05-11).** New `auto_verify` kwarg, default `True`. When set, the SDK sends `X-CueAPI-Verify-Echo: true` request header. Substrate-side (Phase 1; cueapi-core's lane) echoes the body it received back in the response under `body_received`. SDK diffs sent vs received and raises `BodyVerifyMismatchError` on drift (with `sent_body`, `received_body`, `first_divergence_byte`, `message_id` attributes for programmatic recovery / diagnostic output). Catches the caller-side shell-expansion bug class where `body=f"... {dynamic_var} ..."` or worse `body=os.popen(...)` silently mutated body content upstream. Opt-out via `auto_verify=False` for perf-sensitive flows. Backward-compat: SDK no-ops when substrate omits the echo field (pre-Layer-1 behavior unchanged). New helper: `cueapi.exceptions.first_divergence_byte(a, b)` returns the byte index of the first differing position (pure function; re-usable cross-SDK).
910
- `client.cues.bulk_delete(ids)` — delete up to 100 cues in a single call. Returns `{"deleted": [...], "skipped": [...]}`. Per-ID atomic, not batch atomic. Sends `X-Confirm-Destructive: true` header automatically. Wraps `POST /v1/cues/bulk-delete` (cueapi #650). Parity port of cueapi-cli #46. Raises `ValueError` client-side on empty list or > 100 IDs.
1011

1112
## [0.2.0] - 2026-05-01

cueapi/exceptions.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,72 @@ class InvalidScheduleError(CueAPIError):
5656

5757
class CueAPIServerError(CueAPIError):
5858
"""Raised on 5xx — server error."""
59+
60+
61+
class BodyVerifyMismatchError(CueAPIError):
62+
"""Raised when ``messages.send(auto_verify=True)`` detects that the
63+
body the server received differs from the body the caller sent.
64+
65+
Phase 2 of body-verify defense-in-depth (Mike directive 2026-05-11).
66+
Caught when ``X-CueAPI-Verify-Echo: true`` request header is sent +
67+
the server echoes back the received body in the response. The most
68+
likely cause: caller-side shell expansion of ``$(...)`` / backticks /
69+
``${VAR}`` in the body arg BEFORE Python received it (e.g., a bash
70+
invocation that assembled the body via double-quoted-string-with-
71+
metacharacters before passing as argv).
72+
73+
Attributes:
74+
sent_body: The body the SDK sent in the POST request.
75+
received_body: The body the server reports having received.
76+
first_divergence_byte: Zero-based byte offset of the first
77+
differing position; useful for pinpointing single-char drift.
78+
``-1`` when one body is a proper prefix of the other (length
79+
difference rather than content drift).
80+
message_id: The server-assigned message ID (server stored the
81+
mutated content; caller can inspect via ``messages.get(...)``
82+
if needed for diagnostic / recovery purposes).
83+
"""
84+
85+
def __init__(
86+
self,
87+
message: str,
88+
*,
89+
sent_body: str,
90+
received_body: str,
91+
first_divergence_byte: int,
92+
message_id: str,
93+
**kwargs: Any,
94+
):
95+
self.sent_body = sent_body
96+
self.received_body = received_body
97+
self.first_divergence_byte = first_divergence_byte
98+
self.message_id = message_id
99+
super().__init__(message, **kwargs)
100+
101+
def __repr__(self) -> str:
102+
return (
103+
f"BodyVerifyMismatchError(message_id={self.message_id!r}, "
104+
f"first_divergence_byte={self.first_divergence_byte}, "
105+
f"sent_len={len(self.sent_body)}, "
106+
f"received_len={len(self.received_body)})"
107+
)
108+
109+
110+
def first_divergence_byte(a: str, b: str) -> int:
111+
"""Return the byte index of the first differing position between
112+
``a`` and ``b``. Returns ``-1`` when ``a == b`` OR when one is a
113+
proper prefix of the other (length differs but the shorter is a
114+
clean prefix); the caller should distinguish length-mismatch from
115+
content-divergence by comparing ``len(a) == len(b)``.
116+
117+
Pure function; no SDK dependency. Used by
118+
``BodyVerifyMismatchError`` for diagnostic output + can be re-used
119+
cross-SDK (cueapi-cli, cueapi-action).
120+
"""
121+
common_len = min(len(a), len(b))
122+
for i in range(common_len):
123+
if a[i] != b[i]:
124+
return i
125+
# Equal up to common_len. Either fully equal or one is a prefix of
126+
# the other. -1 signals the caller to check length-mismatch.
127+
return -1

cueapi/resources/messages.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,19 @@
55
from datetime import datetime
66
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
77

8+
from cueapi.exceptions import BodyVerifyMismatchError, first_divergence_byte
9+
810
if TYPE_CHECKING:
911
from cueapi.client import CueAPI
1012

13+
# Response field where Layer 1 substrate echoes back the body it received
14+
# (Phase 2 of body-verify defense in depth; Mike directive 2026-05-11).
15+
# Substrate-side ships this field in the 201 response when the request
16+
# included ``X-CueAPI-Verify-Echo: true`` header. Field name locked
17+
# during joint design between cueapi-primary (substrate) + CMA (SDK);
18+
# update if the design Dock spec finalizes a different name.
19+
_VERIFY_ECHO_FIELD = "body_received"
20+
1121

1222
class MessagesResource:
1323
"""Messages API resource.
@@ -35,6 +45,7 @@ def send(
3545
metadata: Optional[Dict[str, Any]] = None,
3646
idempotency_key: Optional[str] = None,
3747
send_at: Optional[Union[str, datetime]] = None,
48+
auto_verify: bool = True,
3849
) -> dict:
3950
"""Send a message.
4051
@@ -110,8 +121,43 @@ def send(
110121
headers: Dict[str, str] = {"X-Cueapi-From-Agent": from_agent}
111122
if idempotency_key is not None:
112123
headers["Idempotency-Key"] = idempotency_key
113-
114-
return self._client._post("/v1/messages", json=payload, headers=headers)
124+
if auto_verify:
125+
# Phase 2 of body-verify defense in depth. When this header is
126+
# set, the substrate echoes the body it received in the 201
127+
# response under the ``body_received`` field. SDK then diffs
128+
# sent vs received + raises BodyVerifyMismatchError on drift.
129+
# Substrate ignores the header when Layer 1 isn't deployed
130+
# yet; SDK no-ops on missing response field. Backward-compat.
131+
headers["X-CueAPI-Verify-Echo"] = "true"
132+
133+
response = self._client._post("/v1/messages", json=payload, headers=headers)
134+
135+
# Verify echo if requested. The substrate-side echo lands in
136+
# response[_VERIFY_ECHO_FIELD] when Layer 1 is deployed; absent
137+
# otherwise (no-op in that case).
138+
if auto_verify and isinstance(response, dict):
139+
received = response.get(_VERIFY_ECHO_FIELD)
140+
if received is not None and received != body:
141+
msg_id = response.get("id", "<unknown>")
142+
divergence = first_divergence_byte(body, received)
143+
if divergence == -1 and len(body) != len(received):
144+
# One body is a proper prefix of the other; length
145+
# mismatch is the signal. Report at boundary of the
146+
# shorter body.
147+
divergence = min(len(body), len(received))
148+
raise BodyVerifyMismatchError(
149+
f"Body received by substrate ({len(received)} bytes) differs from "
150+
f"body sent ({len(body)} bytes); first divergence at byte "
151+
f"{divergence}. Likely cause: caller-side shell expansion of "
152+
f"$(...) / backticks / ${{VAR}} in the body arg before Python "
153+
f"received it. Mitigations: pass body via file (Path.read_text) "
154+
f"or use --message-file in cueapi-cli.",
155+
sent_body=body,
156+
received_body=received,
157+
first_divergence_byte=divergence,
158+
message_id=msg_id,
159+
)
160+
return response
115161

116162
def get(self, msg_id: str) -> dict:
117163
"""Get a single message by ID."""

tests/test_messages_resource.py

Lines changed: 135 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ def test_minimal_body_and_from_header(self):
2020

2121
r.send(from_agent="sender@x", to="recipient@y", body="hi")
2222

23+
# Phase 2 of body-verify defense in depth (Mike directive 2026-05-11):
24+
# auto_verify=True is the new default → X-CueAPI-Verify-Echo header
25+
# always added. Substrate echoes back received body when Layer 1
26+
# deployed; SDK diffs + raises BodyVerifyMismatchError on drift.
2327
mock_client._post.assert_called_once_with(
2428
"/v1/messages",
2529
json={"to": "recipient@y", "body": "hi"},
26-
headers={"X-Cueapi-From-Agent": "sender@x"},
30+
headers={"X-Cueapi-From-Agent": "sender@x", "X-CueAPI-Verify-Echo": "true"},
2731
)
2832

2933
def test_with_all_optionals(self):
@@ -59,6 +63,7 @@ def test_with_all_optionals(self):
5963
assert call.kwargs["headers"] == {
6064
"X-Cueapi-From-Agent": "sender@x",
6165
"Idempotency-Key": "idemp-key-1",
66+
"X-CueAPI-Verify-Echo": "true",
6267
}
6368

6469
def test_omits_expects_reply_when_default(self):
@@ -88,17 +93,18 @@ def test_idempotency_key_too_long_raises_client_side(self):
8893
mock_client._post.assert_not_called()
8994

9095
def test_omits_idempotency_key_header_when_unset(self):
91-
# Headers should ONLY contain X-Cueapi-From-Agent when no
92-
# idempotency_key is passed. Pin so a refactor can't silently
93-
# start adding `Idempotency-Key: None` (httpx would coerce).
96+
# Headers should ONLY contain X-Cueapi-From-Agent (+ default
97+
# auto-verify header) when no idempotency_key is passed. Pin so
98+
# a refactor can't silently start adding `Idempotency-Key: None`
99+
# (httpx would coerce).
94100
mock_client = MagicMock()
95101
mock_client._post.return_value = {"id": "msg_x"}
96102
r = MessagesResource(mock_client)
97103

98104
r.send(from_agent="x", to="y", body="hi")
99105

100106
headers = mock_client._post.call_args.kwargs["headers"]
101-
assert headers == {"X-Cueapi-From-Agent": "x"}
107+
assert headers == {"X-Cueapi-From-Agent": "x", "X-CueAPI-Verify-Echo": "true"}
102108
assert "Idempotency-Key" not in headers
103109

104110

@@ -167,7 +173,7 @@ def test_send_with_send_at_iso_string(self):
167173
"body": "hi",
168174
"send_at": "2030-01-01T12:00:00Z",
169175
},
170-
headers={"X-Cueapi-From-Agent": "sender@x"},
176+
headers={"X-Cueapi-From-Agent": "sender@x", "X-CueAPI-Verify-Echo": "true"},
171177
)
172178

173179
def test_send_with_send_at_datetime_auto_isoformats(self):
@@ -198,3 +204,126 @@ def test_send_without_send_at_omits_field(self):
198204

199205
call = mock_client._post.call_args
200206
assert "send_at" not in call.kwargs["json"]
207+
208+
209+
class TestAutoVerify:
210+
"""Phase 2 of body-verify defense in depth (Mike directive 2026-05-11).
211+
212+
auto_verify=True (default) adds X-CueAPI-Verify-Echo: true header.
213+
Substrate echoes back received body in 201 response under
214+
body_received field; SDK diffs sent vs received + raises
215+
BodyVerifyMismatchError on drift.
216+
"""
217+
218+
def test_default_adds_verify_echo_header(self):
219+
mock_client = MagicMock()
220+
mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"}
221+
r = MessagesResource(mock_client)
222+
223+
r.send(from_agent="x", to="y", body="hi")
224+
225+
headers = mock_client._post.call_args.kwargs["headers"]
226+
assert headers.get("X-CueAPI-Verify-Echo") == "true"
227+
228+
def test_opt_out_omits_header(self):
229+
mock_client = MagicMock()
230+
mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"}
231+
r = MessagesResource(mock_client)
232+
233+
r.send(from_agent="x", to="y", body="hi", auto_verify=False)
234+
235+
headers = mock_client._post.call_args.kwargs["headers"]
236+
assert "X-CueAPI-Verify-Echo" not in headers
237+
238+
def test_byte_identical_response_returns_normally(self):
239+
"""When server echoes back the same body, send() returns response."""
240+
mock_client = MagicMock()
241+
mock_client._post.return_value = {
242+
"id": "msg_x",
243+
"delivery_state": "queued",
244+
"body_received": "hi",
245+
}
246+
r = MessagesResource(mock_client)
247+
248+
result = r.send(from_agent="x", to="y", body="hi")
249+
250+
assert result["id"] == "msg_x"
251+
252+
def test_raises_on_body_mismatch(self):
253+
"""When server echo differs from sent body, raises BodyVerifyMismatchError."""
254+
from cueapi.exceptions import BodyVerifyMismatchError
255+
256+
mock_client = MagicMock()
257+
mock_client._post.return_value = {
258+
"id": "msg_mutated",
259+
"delivery_state": "queued",
260+
"body_received": "body with INJECT (caller's shell command-substituted)",
261+
}
262+
r = MessagesResource(mock_client)
263+
264+
with pytest.raises(BodyVerifyMismatchError) as exc:
265+
r.send(
266+
from_agent="x", to="y",
267+
body="body with $(echo INJECT) (intended literal)",
268+
)
269+
270+
assert exc.value.message_id == "msg_mutated"
271+
assert "$(echo INJECT)" in exc.value.sent_body
272+
assert "INJECT (caller" in exc.value.received_body
273+
assert exc.value.first_divergence_byte >= 0
274+
275+
def test_no_op_when_substrate_omits_echo_field(self):
276+
"""Backward-compat: pre-Layer-1 substrate doesn't include
277+
body_received field → SDK doesn't raise; returns normally."""
278+
mock_client = MagicMock()
279+
mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"}
280+
r = MessagesResource(mock_client)
281+
282+
result = r.send(from_agent="x", to="y", body="hi")
283+
284+
assert result["id"] == "msg_x"
285+
286+
def test_opt_out_skips_verify_even_if_substrate_echoes(self):
287+
"""auto_verify=False: even if substrate sends body_received, don't check."""
288+
mock_client = MagicMock()
289+
# Mismatched echo but opt-out → no exception
290+
mock_client._post.return_value = {
291+
"id": "msg_x", "body_received": "DIFFERENT BODY",
292+
}
293+
r = MessagesResource(mock_client)
294+
295+
result = r.send(from_agent="x", to="y", body="hi", auto_verify=False)
296+
297+
assert result["id"] == "msg_x"
298+
299+
300+
class TestFirstDivergenceByte:
301+
"""Pure helper for diagnostic byte-position-of-first-difference."""
302+
303+
def test_equal_strings_return_minus_one(self):
304+
from cueapi.exceptions import first_divergence_byte
305+
assert first_divergence_byte("abc", "abc") == -1
306+
307+
def test_prefix_returns_minus_one(self):
308+
from cueapi.exceptions import first_divergence_byte
309+
assert first_divergence_byte("abc", "abcd") == -1 # one is prefix of other
310+
311+
def test_first_char_diff(self):
312+
from cueapi.exceptions import first_divergence_byte
313+
assert first_divergence_byte("Xbc", "abc") == 0
314+
315+
def test_middle_diff(self):
316+
from cueapi.exceptions import first_divergence_byte
317+
assert first_divergence_byte("abXd", "abcd") == 2
318+
319+
def test_metachar_substitution_scenario(self):
320+
"""Realistic case: caller's shell substituted $(echo X) → 'X'.
321+
322+
Sent: 'pre $(echo INJ) post' (caller intended literal)
323+
Received: 'pre INJ post' (shell already substituted)
324+
First divergence at byte 4 (start of '$' in sent vs 'I' in received).
325+
"""
326+
from cueapi.exceptions import first_divergence_byte
327+
sent = "pre $(echo INJ) post"
328+
received = "pre INJ post"
329+
assert first_divergence_byte(sent, received) == 4

0 commit comments

Comments
 (0)