From bdd19550cf2c0a0b795f972d86e2db9b0c7d0331 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 11 May 2026 17:46:56 -0700 Subject: [PATCH] feat(cues): add --verify opt-in flag to fire (cueapi-python #41 parity, Mike body-verify directive 2026-05-11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity port of cueapi-python #41 — body-verify Phase 2 on cues.fire, but **OPT-IN** (not default-on) because the substrate's /v1/cues/{id}/fire endpoint echoes a pydantic-after-parse body that may include server-side default-population, causing spurious diff vs the CLI's canonical-JSON serialization. Mirrors primary's #41 design rationale: default OFF; caller opts in with --verify when they know substrate echo semantics match their serialization (typical for the sha256 constant-cost path). Diverges from messages-send body-verify which is default-on (--no-verify opt-out) because that endpoint echoes the raw STRING body field per the spec-lock — no parsed-defaulted shape concern. Implementation: - New --verify click flag (is_flag, default False). Help text documents the OPT-IN rationale + the substrate-echo-shape concern. - When --verify: send X-CueAPI-Verify-Echo: true header; pre-compute sha256(canonical-JSON(body)) hexdigest client-side. - On 2xx response: compare sha256 first (constant-cost). If sha mismatch, fall back to string compare of body_received vs canonical body JSON. Spurious sha mismatch (e.g. canonical-JSON serialization diff) is rescued by the string compare. - Defensive isinstance: body_received as string (post-#798 spec-lock) OR dict (pre-#798 wire shape). Matches the same pattern in cueapi-cli messages-send (#53) and cueapi-python messages.send (#40). - On confirmed mismatch: exit 7 with byte-divergence diagnostic. Uses click.echo + raise SystemExit(7) directly (NOT echo_error which would raise SystemExit(1) and shadow the verify-specific exit code). Tests (4 new): - test_fire_verify_off_by_default_omits_header — no --verify ⇒ no X-CueAPI-Verify-Echo header (preserves pre-#791 wire format) - test_fire_verify_on_sends_header — --verify ⇒ header set + sha match path passes silently - test_fire_verify_help_lists_flag — --help mentions --verify + the opt-in rationale so users discover the design context - test_fire_verify_mismatch_exits_7 — substrate echoes corrupted body ⇒ exit 7 with "body-verify mismatch" diagnostic Full file: 219/219 passing (was 215 + 4 new = 219). Backlog row: cmp1wj0q3. Out of scope: - cueapi-mcp parity (Backlog cmp1wj2a6) — separate PR. --- cueapi/cli.py | 83 +++++++++++++++++++++++++++++++++++++++++++- tests/test_cli.py | 88 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/cueapi/cli.py b/cueapi/cli.py index b7d8369..9ba8bdc 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -1,6 +1,7 @@ """CueAPI CLI — Click command group and all commands.""" from __future__ import annotations +import hashlib import json import re import sys @@ -557,6 +558,22 @@ def bulk_delete(ctx: click.Context, cue_ids: tuple, yes: bool) -> None: "FireRequest schema; diverges from messaging primitive's Idempotency-Key header)." ), ) +@click.option( + "--verify", + "verify", + is_flag=True, + default=False, + help=( + "OPT-IN body-verify Phase 2 (Mike body-verify directive 2026-05-11; parity " + "with cueapi-python #41). When set, the CLI sends X-CueAPI-Verify-Echo + " + "compares substrate-echoed body_received (or body_received_sha256, constant-cost) " + "against the body sent; exits 7 on mismatch with byte-diff diagnostic. Default OFF " + "for cues fire because the substrate /v1/cues/{id}/fire echoes a pydantic-after-parse " + "body that may include server-side default-population, which would cause spurious " + "diff vs the CLI's canonical-JSON serialization. Opt in when you know substrate " + "echo semantics match your serialization (typical for the sha256 constant-cost path)." + ), +) @click.pass_context def fire( ctx: click.Context, @@ -566,6 +583,7 @@ def fire( send_at: Optional[str], exit_criteria: tuple, idempotency_key: Optional[str], + verify: bool, ) -> None: """Fire an existing cue immediately, optionally overriding its payload.""" body: dict = {} @@ -589,9 +607,20 @@ def fire( # from messaging primitive's Idempotency-Key header convention). body["idempotency_key"] = idempotency_key + headers: dict = {} + sent_body_bytes: Optional[bytes] = None + if verify: + # Phase 2 of body-verify defense in depth. Opt-in for cues fire (default + # off — substrate echoes parsed-defaulted body shape; only enable when + # caller knows their serialization matches). + headers["X-CueAPI-Verify-Echo"] = "true" + sent_body_bytes = json.dumps( + body, separators=(",", ":") + ).encode("utf-8") + try: with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: - resp = client.post(f"/cues/{cue_id}/fire", json=body) + resp = client.post(f"/cues/{cue_id}/fire", json=body, headers=headers) if resp.status_code in (200, 201, 202): data = resp.json() exec_id = data.get("id") or data.get("execution_id", "?") @@ -600,6 +629,58 @@ def fire( echo_info("Execution:", exec_id) if scheduled: echo_info("Scheduled:", scheduled) + + # Phase 2 body-verify check (only when --verify is set). + # Defensive isinstance: substrate may emit body_received as + # str (post-#798 spec-lock) or dict (pre-#798 wire shape). + if verify and sent_body_bytes is not None and isinstance(data, dict): + received_raw = data.get("body_received") + received_str: Optional[str] = None + if isinstance(received_raw, str): + received_str = received_raw + elif isinstance(received_raw, dict): + received_str = json.dumps( + received_raw, separators=(",", ":") + ) + + sha_field = data.get("body_received_sha256") + mismatch = False + sent_str = sent_body_bytes.decode("utf-8") + if isinstance(sha_field, str) and len(sha_field) == 64: + # Constant-cost SHA256 compare first; fall back to + # string compare on SHA drift since canonical-JSON + # differences (key order, whitespace) can cause + # spurious hash diff. + client_sha = hashlib.sha256(sent_body_bytes).hexdigest() + if client_sha != sha_field: + if received_str is not None and received_str != sent_str: + mismatch = True + else: + # No SHA field; string compare directly. + if received_str is not None and received_str != sent_str: + mismatch = True + + if mismatch and received_str is not None: + sent_len = len(sent_str) + recv_len = len(received_str) + divergence = _first_divergence_byte(sent_str, received_str) + if divergence == -1 and sent_len != recv_len: + divergence = min(sent_len, recv_len) + # Use click.echo+SystemExit(7) directly — echo_error + # would raise SystemExit(1) and shadow the verify- + # specific exit code 7 (which matches the messages- + # send verify exit shape; same diagnostic surface). + click.echo( + click.style( + f"Error: body-verify mismatch on cues fire (execution={exec_id}): " + f"sent {sent_len} chars, substrate received {recv_len} chars" + + (f", first divergence at byte {divergence}" if divergence >= 0 else "") + + ". Likely caller-side mutation of payload_override before reaching the CLI.", + fg="red", + ), + err=True, + ) + raise SystemExit(7) elif resp.status_code == 404: echo_error(f"Cue not found: {cue_id}") else: diff --git a/tests/test_cli.py b/tests/test_cli.py index 14e44dc..d446bb1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3714,3 +3714,91 @@ def test_subscriptions_create_help_lists_inline_body(): assert "--inline-body" in result.output # Mention the 32KB cap so users discover it via --help assert "32KB" in result.output or "body_omitted" in result.output + + +# --- cues fire --verify (cueapi-python #41 parity; opt-in body-verify Phase 2) --- + + +def test_fire_verify_off_by_default_omits_header(monkeypatch): + """Default (no --verify) MUST NOT send X-CueAPI-Verify-Echo. + Opt-in design — substrate fire echoes parsed-defaulted body that + could cause spurious diff vs canonical-JSON serialization.""" + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/cues/cue_x/fire"): lambda: _FakeResp( + 201, {"id": "exec_1", "scheduled_for": "2026-05-12T00:00:00Z"} + ) + }, + ) + result = runner.invoke(main, ["fire", "cue_x"]) + assert result.exit_code == 0, result.output + # _MessagesClient captures (method, path, body, headers) + _, _, _, headers = holder["client"].calls[-1] + assert headers == {} or "X-CueAPI-Verify-Echo" not in (headers or {}) + + +def test_fire_verify_on_sends_header(monkeypatch): + """--verify sends X-CueAPI-Verify-Echo: true header.""" + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/cues/cue_x/fire"): lambda: _FakeResp( + 201, + { + "id": "exec_1", + "scheduled_for": "2026-05-12T00:00:00Z", + "body_received": "{}", + "body_received_sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + }, + ) + }, + ) + result = runner.invoke(main, ["fire", "cue_x", "--verify"]) + assert result.exit_code == 0, result.output + _, _, _, headers = holder["client"].calls[-1] + assert headers.get("X-CueAPI-Verify-Echo") == "true" + + +def test_fire_verify_help_lists_flag(): + result = runner.invoke(main, ["fire", "--help"]) + assert result.exit_code == 0 + assert "--verify" in result.output + # Mention the OPT-IN rationale so users see the design context + assert "opt-in" in result.output.lower() or "default off" in result.output.lower() or "default OFF" in result.output + + +def test_fire_verify_mismatch_exits_7(monkeypatch): + """Mismatched body_received from substrate triggers exit 7 with diff diagnostic.""" + holder: dict = {} + # Server reports a different body than what we sent (simulates + # caller-side mutation OR substrate misbehavior). + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/cues/cue_x/fire"): lambda: _FakeResp( + 201, + { + "id": "exec_1", + "body_received": '{"payload_override":{"corrupted":true}}', + # SHA doesn't match — but we also include a body_received + # string that differs, so the fallback compare fires. + "body_received_sha256": "0" * 64, + }, + ) + }, + ) + result = runner.invoke( + main, + ["fire", "cue_x", "--verify", + "--payload-override", '{"clean":true}'], + ) + assert result.exit_code == 7, ( + f"expected exit 7 (verify mismatch), got {result.exit_code}; output: {result.output}" + ) + assert "body-verify mismatch" in result.output