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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions cueapi/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 48 additions & 2 deletions cueapi/resources/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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", "<unknown>")
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."""
Expand Down
141 changes: 135 additions & 6 deletions tests/test_messages_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -88,17 +93,18 @@ 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)

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


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Loading