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.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.

Expand Down
94 changes: 93 additions & 1 deletion cueapi/resources/cues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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", "<unknown>")
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
95 changes: 94 additions & 1 deletion tests/test_cues_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,23 @@


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"}
resource = CuesResource(mock_client)

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