diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c0e2a0..6be8e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to cueapi-sdk will be documented here. ### Added +- **`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). - `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. ## [0.2.0] - 2026-05-01 diff --git a/cueapi/exceptions.py b/cueapi/exceptions.py index d7e36b9..6a2fdd1 100644 --- a/cueapi/exceptions.py +++ b/cueapi/exceptions.py @@ -56,3 +56,72 @@ class InvalidScheduleError(CueAPIError): class CueAPIServerError(CueAPIError): """Raised on 5xx — server error.""" + + +class BodyVerifyMismatchError(CueAPIError): + """Raised when ``messages.send(auto_verify=True)`` detects that the + body the server received differs from the body the caller sent. + + Phase 2 of body-verify defense-in-depth (Mike directive 2026-05-11). + Caught when ``X-CueAPI-Verify-Echo: true`` request header is sent + + the server echoes back the received body in the response. The most + likely cause: caller-side shell expansion of ``$(...)`` / backticks / + ``${VAR}`` in the body arg BEFORE Python received it (e.g., a bash + invocation that assembled the body via double-quoted-string-with- + metacharacters before passing as argv). + + Attributes: + sent_body: The body the SDK sent in the POST request. + received_body: The body the server reports having received. + first_divergence_byte: Zero-based byte offset of the first + differing position; useful for pinpointing single-char drift. + ``-1`` when one body is a proper prefix of the other (length + difference rather than content drift). + message_id: The server-assigned message ID (server stored the + mutated content; caller can inspect via ``messages.get(...)`` + if needed for diagnostic / recovery purposes). + """ + + def __init__( + self, + message: str, + *, + sent_body: str, + received_body: str, + first_divergence_byte: int, + message_id: str, + **kwargs: Any, + ): + self.sent_body = sent_body + self.received_body = received_body + self.first_divergence_byte = first_divergence_byte + self.message_id = message_id + super().__init__(message, **kwargs) + + def __repr__(self) -> str: + return ( + f"BodyVerifyMismatchError(message_id={self.message_id!r}, " + f"first_divergence_byte={self.first_divergence_byte}, " + f"sent_len={len(self.sent_body)}, " + f"received_len={len(self.received_body)})" + ) + + +def first_divergence_byte(a: str, b: str) -> int: + """Return the byte index of the first differing position between + ``a`` and ``b``. Returns ``-1`` when ``a == b`` OR when one is a + proper prefix of the other (length differs but the shorter is a + clean prefix); the caller should distinguish length-mismatch from + content-divergence by comparing ``len(a) == len(b)``. + + Pure function; no SDK dependency. Used by + ``BodyVerifyMismatchError`` for diagnostic output + can be re-used + cross-SDK (cueapi-cli, cueapi-action). + """ + common_len = min(len(a), len(b)) + for i in range(common_len): + if a[i] != b[i]: + return i + # Equal up to common_len. Either fully equal or one is a prefix of + # the other. -1 signals the caller to check length-mismatch. + return -1 diff --git a/cueapi/resources/messages.py b/cueapi/resources/messages.py index 4d565f3..55c29fd 100644 --- a/cueapi/resources/messages.py +++ b/cueapi/resources/messages.py @@ -5,9 +5,19 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, Dict, Optional, Union +from cueapi.exceptions import BodyVerifyMismatchError, first_divergence_byte + if TYPE_CHECKING: from cueapi.client import CueAPI +# Response field where Layer 1 substrate echoes back the body it received +# (Phase 2 of body-verify defense in depth; Mike directive 2026-05-11). +# Substrate-side ships this field in the 201 response when the request +# included ``X-CueAPI-Verify-Echo: true`` header. Field name locked +# during joint design between cueapi-primary (substrate) + CMA (SDK); +# update if the design Dock spec finalizes a different name. +_VERIFY_ECHO_FIELD = "body_received" + class MessagesResource: """Messages API resource. @@ -35,6 +45,7 @@ def send( metadata: Optional[Dict[str, Any]] = None, idempotency_key: Optional[str] = None, send_at: Optional[Union[str, datetime]] = None, + auto_verify: bool = True, ) -> dict: """Send a message. @@ -110,8 +121,43 @@ def send( headers: Dict[str, str] = {"X-Cueapi-From-Agent": from_agent} if idempotency_key is not None: headers["Idempotency-Key"] = idempotency_key - - return self._client._post("/v1/messages", json=payload, headers=headers) + if auto_verify: + # Phase 2 of body-verify defense in depth. When this header is + # set, the substrate echoes the body it received in the 201 + # response under the ``body_received`` field. SDK then diffs + # sent vs received + raises BodyVerifyMismatchError on drift. + # Substrate ignores the header when Layer 1 isn't deployed + # yet; SDK no-ops on missing response field. Backward-compat. + headers["X-CueAPI-Verify-Echo"] = "true" + + response = self._client._post("/v1/messages", json=payload, headers=headers) + + # Verify echo if requested. The substrate-side echo lands in + # response[_VERIFY_ECHO_FIELD] when Layer 1 is deployed; absent + # otherwise (no-op in that case). + if auto_verify and isinstance(response, dict): + received = response.get(_VERIFY_ECHO_FIELD) + if received is not None and received != body: + msg_id = response.get("id", "") + divergence = first_divergence_byte(body, received) + if divergence == -1 and len(body) != len(received): + # One body is a proper prefix of the other; length + # mismatch is the signal. Report at boundary of the + # shorter body. + divergence = min(len(body), len(received)) + raise BodyVerifyMismatchError( + f"Body received by substrate ({len(received)} bytes) differs from " + f"body sent ({len(body)} bytes); first divergence at byte " + f"{divergence}. Likely cause: caller-side shell expansion of " + f"$(...) / backticks / ${{VAR}} in the body arg before Python " + f"received it. Mitigations: pass body via file (Path.read_text) " + f"or use --message-file in cueapi-cli.", + sent_body=body, + received_body=received, + first_divergence_byte=divergence, + message_id=msg_id, + ) + return response def get(self, msg_id: str) -> dict: """Get a single message by ID.""" diff --git a/tests/test_messages_resource.py b/tests/test_messages_resource.py index 02f54c7..86c2903 100644 --- a/tests/test_messages_resource.py +++ b/tests/test_messages_resource.py @@ -20,10 +20,14 @@ def test_minimal_body_and_from_header(self): r.send(from_agent="sender@x", to="recipient@y", body="hi") + # Phase 2 of body-verify defense in depth (Mike directive 2026-05-11): + # auto_verify=True is the new default → X-CueAPI-Verify-Echo header + # always added. Substrate echoes back received body when Layer 1 + # deployed; SDK diffs + raises BodyVerifyMismatchError on drift. mock_client._post.assert_called_once_with( "/v1/messages", json={"to": "recipient@y", "body": "hi"}, - headers={"X-Cueapi-From-Agent": "sender@x"}, + headers={"X-Cueapi-From-Agent": "sender@x", "X-CueAPI-Verify-Echo": "true"}, ) def test_with_all_optionals(self): @@ -59,6 +63,7 @@ def test_with_all_optionals(self): assert call.kwargs["headers"] == { "X-Cueapi-From-Agent": "sender@x", "Idempotency-Key": "idemp-key-1", + "X-CueAPI-Verify-Echo": "true", } def test_omits_expects_reply_when_default(self): @@ -88,9 +93,10 @@ def test_idempotency_key_too_long_raises_client_side(self): mock_client._post.assert_not_called() def test_omits_idempotency_key_header_when_unset(self): - # Headers should ONLY contain X-Cueapi-From-Agent when no - # idempotency_key is passed. Pin so a refactor can't silently - # start adding `Idempotency-Key: None` (httpx would coerce). + # Headers should ONLY contain X-Cueapi-From-Agent (+ default + # auto-verify header) when no idempotency_key is passed. Pin so + # a refactor can't silently start adding `Idempotency-Key: None` + # (httpx would coerce). mock_client = MagicMock() mock_client._post.return_value = {"id": "msg_x"} r = MessagesResource(mock_client) @@ -98,7 +104,7 @@ def test_omits_idempotency_key_header_when_unset(self): r.send(from_agent="x", to="y", body="hi") headers = mock_client._post.call_args.kwargs["headers"] - assert headers == {"X-Cueapi-From-Agent": "x"} + assert headers == {"X-Cueapi-From-Agent": "x", "X-CueAPI-Verify-Echo": "true"} assert "Idempotency-Key" not in headers @@ -167,7 +173,7 @@ def test_send_with_send_at_iso_string(self): "body": "hi", "send_at": "2030-01-01T12:00:00Z", }, - headers={"X-Cueapi-From-Agent": "sender@x"}, + headers={"X-Cueapi-From-Agent": "sender@x", "X-CueAPI-Verify-Echo": "true"}, ) def test_send_with_send_at_datetime_auto_isoformats(self): @@ -198,3 +204,126 @@ def test_send_without_send_at_omits_field(self): call = mock_client._post.call_args assert "send_at" not in call.kwargs["json"] + + +class TestAutoVerify: + """Phase 2 of body-verify defense in depth (Mike directive 2026-05-11). + + auto_verify=True (default) adds X-CueAPI-Verify-Echo: true header. + Substrate echoes back received body in 201 response under + body_received field; SDK diffs sent vs received + raises + BodyVerifyMismatchError on drift. + """ + + def test_default_adds_verify_echo_header(self): + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + r.send(from_agent="x", to="y", body="hi") + + headers = mock_client._post.call_args.kwargs["headers"] + assert headers.get("X-CueAPI-Verify-Echo") == "true" + + def test_opt_out_omits_header(self): + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + r.send(from_agent="x", to="y", body="hi", auto_verify=False) + + headers = mock_client._post.call_args.kwargs["headers"] + assert "X-CueAPI-Verify-Echo" not in headers + + def test_byte_identical_response_returns_normally(self): + """When server echoes back the same body, send() returns response.""" + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "msg_x", + "delivery_state": "queued", + "body_received": "hi", + } + r = MessagesResource(mock_client) + + result = r.send(from_agent="x", to="y", body="hi") + + assert result["id"] == "msg_x" + + def test_raises_on_body_mismatch(self): + """When server echo differs from sent body, raises BodyVerifyMismatchError.""" + from cueapi.exceptions import BodyVerifyMismatchError + + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "msg_mutated", + "delivery_state": "queued", + "body_received": "body with INJECT (caller's shell command-substituted)", + } + r = MessagesResource(mock_client) + + with pytest.raises(BodyVerifyMismatchError) as exc: + r.send( + from_agent="x", to="y", + body="body with $(echo INJECT) (intended literal)", + ) + + assert exc.value.message_id == "msg_mutated" + assert "$(echo INJECT)" in exc.value.sent_body + assert "INJECT (caller" in exc.value.received_body + assert exc.value.first_divergence_byte >= 0 + + def test_no_op_when_substrate_omits_echo_field(self): + """Backward-compat: pre-Layer-1 substrate doesn't include + body_received field → SDK doesn't raise; returns normally.""" + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + result = r.send(from_agent="x", to="y", body="hi") + + assert result["id"] == "msg_x" + + def test_opt_out_skips_verify_even_if_substrate_echoes(self): + """auto_verify=False: even if substrate sends body_received, don't check.""" + mock_client = MagicMock() + # Mismatched echo but opt-out → no exception + mock_client._post.return_value = { + "id": "msg_x", "body_received": "DIFFERENT BODY", + } + r = MessagesResource(mock_client) + + result = r.send(from_agent="x", to="y", body="hi", auto_verify=False) + + assert result["id"] == "msg_x" + + +class TestFirstDivergenceByte: + """Pure helper for diagnostic byte-position-of-first-difference.""" + + def test_equal_strings_return_minus_one(self): + from cueapi.exceptions import first_divergence_byte + assert first_divergence_byte("abc", "abc") == -1 + + def test_prefix_returns_minus_one(self): + from cueapi.exceptions import first_divergence_byte + assert first_divergence_byte("abc", "abcd") == -1 # one is prefix of other + + def test_first_char_diff(self): + from cueapi.exceptions import first_divergence_byte + assert first_divergence_byte("Xbc", "abc") == 0 + + def test_middle_diff(self): + from cueapi.exceptions import first_divergence_byte + assert first_divergence_byte("abXd", "abcd") == 2 + + def test_metachar_substitution_scenario(self): + """Realistic case: caller's shell substituted $(echo X) → 'X'. + + Sent: 'pre $(echo INJ) post' (caller intended literal) + Received: 'pre INJ post' (shell already substituted) + First divergence at byte 4 (start of '$' in sent vs 'I' in received). + """ + from cueapi.exceptions import first_divergence_byte + sent = "pre $(echo INJ) post" + received = "pre INJ post" + assert first_divergence_byte(sent, received) == 4