diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be8e58..203e49c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to cueapi-sdk will be documented here. ### Added +- **`client.cues.fire(auto_verify=False)` body-verify mirror — OPT-IN (Mike body-verify directive 2026-05-11).** Parallel to `MessagesResource.send(auto_verify=True)`. Default OFF for cues fire because substrate's `/v1/cues/{id}/fire` echoes a pydantic-after-parse body that may include server-side default-population (verified empirically against staging CI ~23:48Z); diffing client's canonical-JSON vs substrate's parsed-defaulted echo would cause spurious mismatch. Callers can opt-in via `auto_verify=True` when they know substrate echo semantics match client serialization (typical for sha256-based constant-cost path). Implementation includes sha256 hex compare (constant-cost) with string-compare fallback on hash drift. On confirmed drift raises `BodyVerifyMismatchError` with diagnostic attributes including `message_id` (= execution_id for fire). Defensive isinstance handles both dict (pre-substrate-fix) and string (post-fix 2026-05-11 ~23:48Z) wire shapes. When cueapi-primary locks per-field echo semantics for fire, default will flip to True. - **`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. diff --git a/cueapi/resources/cues.py b/cueapi/resources/cues.py index a14ab20..851496b 100644 --- a/cueapi/resources/cues.py +++ b/cueapi/resources/cues.py @@ -2,14 +2,26 @@ from __future__ import annotations +import hashlib +import json from datetime import datetime from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from cueapi.exceptions import BodyVerifyMismatchError, first_divergence_byte from cueapi.models.cue import Cue, CueList if TYPE_CHECKING: from cueapi.client import CueAPI +# Phase 2 of body-verify defense in depth (Mike directive 2026-05-11). +# Substrate echoes the request body bytes back in body_received (str) +# + body_received_sha256 (64-hex SHA256 of the same bytes) when the +# X-CueAPI-Verify-Echo: true request header is set. Field names locked +# during joint design (CMA + cueapi-primary) on Dock workspace +# cue-message-silent-corruption-substrate-design-2026-05-11. +_VERIFY_ECHO_BODY_FIELD = "body_received" +_VERIFY_ECHO_SHA256_FIELD = "body_received_sha256" + class CuesResource: """Manage cues (scheduled tasks).""" @@ -273,6 +285,7 @@ def fire( send_at: Optional[Union[str, datetime]] = None, exit_criteria: Optional[List[str]] = None, idempotency_key: Optional[str] = None, + auto_verify: bool = False, ) -> Dict[str, Any]: """Fire an existing cue, optionally overriding payload + scheduling. @@ -344,4 +357,83 @@ def fire( if idempotency_key is not None: body["idempotency_key"] = idempotency_key - return self._client._post(f"/v1/cues/{cue_id}/fire", json=body) + # Phase 2 of body-verify defense in depth (Mike directive 2026-05-11). + # Substrate echoes request body bytes back as body_received (str) + + # body_received_sha256 (64-hex SHA256) when X-CueAPI-Verify-Echo: + # true header is set. We compute the same SHA256 client-side over + # our request body JSON + compare hex (constant cost) — falls back + # to string compare on body_received string if available. Mirrors + # MessagesResource.send auto_verify pattern. + headers: Dict[str, str] = {} + sent_body_bytes: Optional[bytes] = None + if auto_verify: + headers["X-CueAPI-Verify-Echo"] = "true" + # Pre-compute canonical JSON bytes for the verify-echo + # comparison. Server hashes the body bytes it received; + # this client hashes the body bytes we send. Match should + # be byte-identical if no transport-layer mutation occurred. + sent_body_bytes = json.dumps(body, separators=(",", ":")).encode("utf-8") + + response = self._client._post( + f"/v1/cues/{cue_id}/fire", json=body, headers=headers + ) + + # Verify echo if requested. Defensive isinstance handles both + # current substrate (flat string post-fix 2026-05-11 ~23:48Z) + # and the earlier dict-shape variant + the no-echo backward- + # compat path. + if auto_verify and isinstance(response, dict) and sent_body_bytes is not None: + received_raw = response.get(_VERIFY_ECHO_BODY_FIELD) + received_str: Optional[str] = None + if isinstance(received_raw, str): + received_str = received_raw + elif isinstance(received_raw, dict): + # Pre-fix wire shape: serialize for compare. Future- + # proof in case any deployment still ships the dict. + received_str = json.dumps(received_raw, separators=(",", ":")) + + # Prefer constant-cost SHA256 comparison when both server + + # client compute the same digest. Falls back to string + # compare if the sha field is absent. + sha_field = response.get(_VERIFY_ECHO_SHA256_FIELD) + mismatch_detected = False + if isinstance(sha_field, str) and len(sha_field) == 64: + # Server's sha256 hashes the raw request bytes it + # received. We compute over our locally-serialized + # bytes. JSON-canonicalization differences (key order, + # whitespace) could cause spurious mismatch — so on + # sha mismatch, fall back to string-compare which is + # more forgiving on serialization differences. + client_sha = hashlib.sha256(sent_body_bytes).hexdigest() + if client_sha != sha_field: + # SHA mismatch — verify with string compare; if THAT + # also fails, it's a real divergence. + if received_str is not None and received_str != json.dumps( + body, separators=(",", ":") + ): + mismatch_detected = True + else: + # No sha field; compare body_received string vs our + # canonical body JSON. + if received_str is not None and received_str != json.dumps( + body, separators=(",", ":") + ): + mismatch_detected = True + + if mismatch_detected and received_str is not None: + exec_id = response.get("id", "") + sent_str = json.dumps(body, separators=(",", ":")) + divergence = first_divergence_byte(sent_str, received_str) + if divergence == -1 and len(sent_str) != len(received_str): + divergence = min(len(sent_str), len(received_str)) + raise BodyVerifyMismatchError( + f"Cue fire body received by substrate ({len(received_str)} bytes) differs " + f"from body sent ({len(sent_str)} bytes); first divergence at byte " + f"{divergence}. Likely cause: caller-side mutation of payload_override or " + f"send_at fields before reaching the SDK. Inspect the dict you constructed.", + sent_body=sent_str, + received_body=received_str, + first_divergence_byte=divergence, + message_id=exec_id, # execution id for fire (NOT message id) + ) + return response diff --git a/tests/test_cues_resource.py b/tests/test_cues_resource.py index b5d40eb..cb903e8 100644 --- a/tests/test_cues_resource.py +++ b/tests/test_cues_resource.py @@ -6,6 +6,13 @@ class TestFire: + """Default ``auto_verify=False`` for ``CuesResource.fire`` — substrate's + /v1/cues/{id}/fire echoes a pydantic-after-parse body that may include + server-side default-population, causing spurious diff vs caller's + canonical-JSON serialization. Until field-by-field echo semantic is + locked with cueapi-primary, fire's auto_verify is opt-in via explicit + kwarg. TestFireAutoVerify class below pins the opt-in verify behavior.""" + def test_fire_no_payload_override(self): mock_client = MagicMock() mock_client._post.return_value = {"id": "exec_test", "status": "queued"} @@ -13,7 +20,9 @@ def test_fire_no_payload_override(self): result = resource.fire("cue_abc123") - mock_client._post.assert_called_once_with("/v1/cues/cue_abc123/fire", json={}) + mock_client._post.assert_called_once_with( + "/v1/cues/cue_abc123/fire", json={}, headers={}, + ) assert result["id"] == "exec_test" def test_fire_with_payload_override_only(self): @@ -27,6 +36,7 @@ def test_fire_with_payload_override_only(self): mock_client._post.assert_called_once_with( "/v1/cues/cue_abc123/fire", json={"payload_override": payload}, + headers={}, ) def test_fire_with_payload_override_and_merge_strategy(self): @@ -40,8 +50,91 @@ def test_fire_with_payload_override_and_merge_strategy(self): mock_client._post.assert_called_once_with( "/v1/cues/cue_abc123/fire", json={"payload_override": payload, "merge_strategy": "replace"}, + headers={}, + ) + + +class TestFireAutoVerify: + """Phase 2 cues fire auto-verify (Mike body-verify directive 2026-05-11). + + Mirrors MessagesResource.send pattern. Substrate echoes back the request + body bytes under body_received + sha256 hex under body_received_sha256. + SDK compares + raises BodyVerifyMismatchError on drift. + """ + + def test_default_off_omits_verify_echo_header(self): + """auto_verify defaults to False on fire (substrate echo semantics + not yet locked for /v1/cues/{id}/fire; opt-in until field-by-field + semantic confirmed with cueapi-primary).""" + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_x"} + resource = CuesResource(mock_client) + + resource.fire("cue_abc") + + headers = mock_client._post.call_args.kwargs.get("headers", {}) + assert "X-CueAPI-Verify-Echo" not in headers + + def test_opt_in_adds_verify_echo_header(self): + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_x"} + resource = CuesResource(mock_client) + + resource.fire("cue_abc", auto_verify=True) + + headers = mock_client._post.call_args.kwargs.get("headers", {}) + assert headers.get("X-CueAPI-Verify-Echo") == "true" + + def test_byte_identical_sha256_passes(self): + """When server's body_received_sha256 matches client's computed + sha256, send() returns response normally (constant-cost path). + Requires explicit auto_verify=True since fire defaults to off.""" + import hashlib + import json + # Compute expected sha256 of the canonical request body + body_payload = {"payload_override": {"task": "test"}} + expected_sha = hashlib.sha256( + json.dumps(body_payload, separators=(",", ":")).encode("utf-8") + ).hexdigest() + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "exec_x", + "body_received": json.dumps(body_payload, separators=(",", ":")), + "body_received_sha256": expected_sha, + } + resource = CuesResource(mock_client) + + result = resource.fire( + "cue_abc", payload_override={"task": "test"}, auto_verify=True ) + assert result["id"] == "exec_x" + + def test_no_op_when_substrate_omits_echo_field(self): + """Pre-Layer-1 substrate (or default-off path) omits echo → no raise.""" + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_x"} + resource = CuesResource(mock_client) + + result = resource.fire("cue_abc", auto_verify=True) + + assert result["id"] == "exec_x" + + def test_default_off_skips_verify_even_if_substrate_echoes(self): + """Default auto_verify=False: even if substrate sends body_received + (e.g. caller targets a different SDK that opted in), this call + doesn't check. Pins the default-off invariant.""" + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "exec_x", + "body_received": "completely different body", + } + resource = CuesResource(mock_client) + + result = resource.fire("cue_abc") # default auto_verify=False + + assert result["id"] == "exec_x" + def test_fire_omits_merge_strategy_when_not_passed(self): # When the caller omits merge_strategy, the wrapper must NOT send a # client-side default. The server's Pydantic default of "merge"