diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a0636..425df93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to cueapi-cli will be documented here. ## [Unreleased] ### Added +- **`messages send` + `message-to`: Phase 2 body-verify auto-echo (Mike body-verify directive 2026-05-11).** Default verify-on. CLI sends `X-CueAPI-Verify-Echo: true` request header; substrate-side (Phase 1 / cueapi-core) echoes back received body in the 201 response under `body_received`. CLI diffs sent vs received; on drift exits with code 7 + diagnostic showing byte position of first divergence + first 200 chars of each side + actionable mitigation hint. `--no-verify` flag opts out (rare; perf-sensitive flows only; verify adds zero substrate roundtrips since echo rides in the existing POST response). Backward-compat: pre-Layer-1 substrate omits `body_received` field → CLI no-ops, returns success unchanged. - **`messages send` + `message-to`: Layer 3 force-file mode (Mike body-verify directive 2026-05-11).** Three body sources accepted (exactly one required): `--message-file ` (RECOMMENDED for content with shell metacharacters; zero shell interpolation), `--body-stdin` (read from stdin; for shell-pipe ergonomics), or `--body ` (auto-rejected when content contains `$(...)`, backticks, or `${VAR}`). Inline body with metachars rejected with actionable error suggesting safer paths; override via `--allow-inline-metachars` for legitimate literal-metachar content (e.g., shell-tutorial examples). Closes the caller-side shell-expansion bug class where `BODY="...$(echo X)..."` silently mutates body content at variable-assignment time before reaching the CLI. Design Dock: `cue-message-silent-corruption-substrate-design-2026-05-11`. - `cueapi message-to ` top-level wrapper for sending a message by name. Resolves `` against your agent roster: `agent_id` (`agt_*`) and slug-form (`slug@user`) pass through unchanged; bare names match case-insensitively against `display_name` and `slug` via `GET /agents`. Same flag set as `messages send` (sans `--to`). - `agents list --online-only` shortcut for `--status online`. Mutually exclusive with `--status`. diff --git a/cueapi/cli.py b/cueapi/cli.py index 4648903..637d666 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -1896,6 +1896,46 @@ def workers_delete(ctx: click.Context, worker_id: str, yes: bool) -> None: _BODY_METACHAR_RE = re.compile(r"\$\(|`|\$\{") +def _first_divergence_byte(a: str, b: str) -> int: + """Return byte index of first differing position. -1 if equal OR + one is a proper prefix of the other (caller distinguishes via + len() comparison).""" + common = min(len(a), len(b)) + for i in range(common): + if a[i] != b[i]: + return i + return -1 + + +def _emit_body_verify_mismatch_diagnostic(*, sent: str, received: str, msg_id: str) -> None: + """Print actionable diff + byte-position-of-first-divergence per + Q-C3 contract. CLI exits 7 after this is called (caller responsibility).""" + divergence = _first_divergence_byte(sent, received) + if divergence == -1 and len(sent) != len(received): + # One body is a proper prefix of the other — length mismatch. + divergence = min(len(sent), len(received)) + click.echo( + f"[cueapi-cli] body verify-echo MISMATCH at byte {divergence}", + err=True, + ) + click.echo( + f"sent ({len(sent)} bytes, first 200 chars): {sent[:200]!r}", + err=True, + ) + click.echo( + f"received ({len(received)} bytes, first 200 chars): {received[:200]!r}", + err=True, + ) + click.echo("", err=True) + click.echo( + "Caller's shell may have expanded $(...)/backticks/${VAR} in body BEFORE " + "cueapi-cli received the arg. Fix: use --message-file (mint body via " + "heredoc with quoted EOF marker) or --body-stdin to pipe content.", + err=True, + ) + click.echo(f"Message ID (mutated content stored server-side): {msg_id}", err=True) + + def _acquire_message_body( body_text: Optional[str], message_file: Optional[str], @@ -2016,6 +2056,20 @@ def messages() -> None: "literal $(...) text). Otherwise prefer --message-file." ), ) +@click.option( + "--no-verify", + "no_verify", + is_flag=True, + default=False, + help=( + "Opt out of Phase 2 body-verify auto-echo (Mike body-verify " + "directive 2026-05-11). Default is verify-on: SDK sends " + "X-CueAPI-Verify-Echo header + checks substrate-echoed body matches " + "sent body, exiting 7 with diff on drift. Use --no-verify for " + "perf-sensitive flows (rare; verify adds zero substrate roundtrips " + "since the echo rides in the same POST response)." + ), +) @click.option("--subject", default=None, help="Optional subject line (max 255 chars)") @click.option( "--reply-to", @@ -2104,6 +2158,7 @@ def messages_send( message_file: Optional[str], body_stdin: bool, allow_inline_metachars: bool, + no_verify: bool, subject: Optional[str], reply_to: Optional[str], priority: Optional[int], @@ -2161,12 +2216,28 @@ def messages_send( if len(idempotency_key) > 255: raise click.UsageError("--idempotency-key must be ≤255 characters") headers["Idempotency-Key"] = idempotency_key + if not no_verify: + # Phase 2 of body-verify defense in depth (Mike directive 2026-05-11). + # Substrate echoes body_received in the 201 response when this header + # is set. CLI diffs sent vs received + exits 7 on mismatch with diff + # output. --no-verify opts out (rare; perf-sensitive flows only). + headers["X-CueAPI-Verify-Echo"] = "true" try: with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: resp = client.post("/messages", json=body, headers=headers) if resp.status_code in (200, 201): m = resp.json() + # Phase 2 body-verify check. Substrate echoes body_received + # when header set; missing field = pre-Layer-1 substrate, no-op. + if not no_verify and isinstance(m, dict): + received = m.get("body_received") + if received is not None and received != resolved_body: + _emit_body_verify_mismatch_diagnostic( + sent=resolved_body, received=received, + msg_id=m.get("id", ""), + ) + ctx.exit(7) click.echo() if resp.status_code == 200: # Dedup hit on Idempotency-Key — same key + same body returned @@ -2406,6 +2477,13 @@ def _resolve_recipient(client, recipient: str) -> str: default=False, help="Override the Layer 3 force-file guard for legitimate literal-metachar inline content.", ) +@click.option( + "--no-verify", + "no_verify", + is_flag=True, + default=False, + help="Opt out of Phase 2 body-verify auto-echo. Default verify-on.", +) @click.option("--subject", default=None, help="Optional subject line (max 255 chars)") @click.option( "--reply-to", @@ -2491,6 +2569,7 @@ def message_to( message_file: Optional[str], body_stdin: bool, allow_inline_metachars: bool, + no_verify: bool, subject: Optional[str], reply_to: Optional[str], priority: Optional[int], @@ -2551,6 +2630,9 @@ def message_to( if len(idempotency_key) > 255: raise click.UsageError("--idempotency-key must be ≤255 characters") headers["Idempotency-Key"] = idempotency_key + if not no_verify: + # Phase 2 body-verify echo (mirror of messages_send). + headers["X-CueAPI-Verify-Echo"] = "true" try: with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: @@ -2564,6 +2646,14 @@ def message_to( resp = client.post("/messages", json=body, headers=headers) if resp.status_code in (200, 201): m = resp.json() + if not no_verify and isinstance(m, dict): + received = m.get("body_received") + if received is not None and received != resolved_body: + _emit_body_verify_mismatch_diagnostic( + sent=resolved_body, received=received, + msg_id=m.get("id", ""), + ) + ctx.exit(7) click.echo() if resp.status_code == 200: echo_info("Idempotency-Key dedup hit:", "existing message returned") diff --git a/tests/test_cli.py b/tests/test_cli.py index 4bc6253..9a615ac 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2046,6 +2046,115 @@ def test_message_to_rejects_inline_body_with_metachars(): assert "shell metacharacters" in r.output +# --- Phase 2: auto-verify body echo --- + + +def test_messages_send_default_adds_verify_echo_header(monkeypatch): + """Default auto-verify-on adds X-CueAPI-Verify-Echo: true header.""" + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_x", "delivery_state": "queued"}, + ) + }, + ) + r = runner.invoke( + main, + ["messages", "send", "--from", "a@x", "--to", "b@y", "--body", "plain"], + ) + assert r.exit_code == 0, r.output + _, _, _, headers = holder["client"].calls[-1] + assert headers.get("X-CueAPI-Verify-Echo") == "true" + + +def test_messages_send_no_verify_omits_header(monkeypatch): + """--no-verify opt-out omits the X-CueAPI-Verify-Echo header.""" + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_x", "delivery_state": "queued"}, + ) + }, + ) + r = runner.invoke( + main, + ["messages", "send", "--from", "a@x", "--to", "b@y", + "--body", "plain", "--no-verify"], + ) + assert r.exit_code == 0, r.output + _, _, _, headers = holder["client"].calls[-1] + assert "X-CueAPI-Verify-Echo" not in headers + + +def test_messages_send_verify_passes_byte_identical(monkeypatch): + """Substrate echoes back same body → success.""" + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_x", "delivery_state": "queued", + "body_received": "plain"}, + ) + }, + ) + r = runner.invoke( + main, + ["messages", "send", "--from", "a@x", "--to", "b@y", "--body", "plain"], + ) + assert r.exit_code == 0, r.output + + +def test_messages_send_verify_fails_loud_on_mismatch(monkeypatch): + """Substrate echoes back DIFFERENT body → exit 7 + diff diagnostic.""" + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_mutated", "delivery_state": "queued", + "body_received": "body received by substrate (mutated)"}, + ) + }, + ) + r = runner.invoke( + main, + ["messages", "send", "--from", "a@x", "--to", "b@y", + "--body", "body sent by caller (intended)"], + ) + # Exit code 7 = body verify mismatch (distinct from generic failure) + assert r.exit_code == 7 + assert "MISMATCH" in r.output + + +def test_messages_send_verify_noop_when_substrate_omits_echo(monkeypatch): + """Backward-compat: pre-Layer-1 substrate omits body_received → no raise.""" + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_x", "delivery_state": "queued"}, + # No body_received field + ) + }, + ) + r = runner.invoke( + main, + ["messages", "send", "--from", "a@x", "--to", "b@y", "--body", "plain"], + ) + assert r.exit_code == 0, r.output + + # --- send body + headers ---