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
9 changes: 6 additions & 3 deletions app/routers/cues.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,10 @@ async def fire_cue(
"status": "pending", "triggered_by": "manual_fire",
}
# BodyVerify Layer 1: opt-in echo-back when caller sets
# X-CueAPI-Verify-Echo: true. Helper returns {} when header absent
# so non-opted clients see zero shape change.
response_content.update(apply_verify_echo(request=request, parsed_body=body))
# X-CueAPI-Verify-Echo: true. OSS FireRequest carries only send_at
# (datetime) — no string user-content field to echo. Helper returns
# body_received=None + sha256 of empty bytes when header set, or {} when
# absent. Once OSS adds a content-bearing field (e.g. payload_override
# for parity), update this to extract the string per spec.
response_content.update(apply_verify_echo(request=request, body_text=None))
return response_content
2 changes: 1 addition & 1 deletion app/routers/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ async def send_message(
# BodyVerify Layer 1: opt-in echo-back when caller sets
# X-CueAPI-Verify-Echo: true. Helper returns {} when header absent
# so non-opted clients see zero shape change.
response_content.update(apply_verify_echo(request=request, parsed_body=body))
response_content.update(apply_verify_echo(request=request, body_text=body.body))
return JSONResponse(
status_code=status_code,
content=response_content,
Expand Down
73 changes: 39 additions & 34 deletions app/utils/verify_echo.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

When request header ``X-CueAPI-Verify-Echo: true`` is present on a supported
endpoint, the server adds ``body_received`` and ``body_received_sha256``
fields to the 200 response. The caller diffs sent body vs received to detect
caller-side shell expansion (backticks, $-paren, ${VAR}) that silently
corrupts body content before send-time.
fields to the 200/201 response. The caller diffs sent body vs received to
detect caller-side shell expansion (backticks, $-paren, ${VAR}) that
silently corrupts body content before send-time.

Why this exists: 2026-05-11 ~22:00Z, CMA's outbound Cue Messages 0/6 to
cue-pm fell to garbage via inline bash ``-d "$BODY"`` where $BODY had been
Expand All @@ -13,12 +13,20 @@
keystone for the 4-layer defense: substrate (this), SDK auto-verify, CLI
force-file mode, docs leading with file-payload pattern.

Spec shape (locked at design review, Phase 1 hotfix corrected post-merge):

* ``body_received`` is the **STRING** value of the body field the caller
sent (e.g. ``MessageCreate.body`` on /v1/messages, ``payload_override.message``
or similar on /v1/cues/<id>/fire). NOT the full parsed Pydantic envelope dump.
* ``body_received_sha256`` is the SHA256 hex digest of those exact UTF-8 bytes
so a caller can compute ``sha256(sent_body_bytes).hexdigest()`` locally and
compare directly. Hash-of-the-string == hash-of-the-bytes.

Design doc: https://trydock.ai/workspaces/cue-message-silent-corruption-substrate-design-2026-05-11
"""
from __future__ import annotations

import hashlib
import json
from typing import Any, Dict, Optional

from fastapi import Request
Expand All @@ -32,51 +40,48 @@ def verify_echo_requested(request: Request) -> bool:
return request.headers.get(VERIFY_ECHO_HEADER, "").strip().lower() == "true"


def _canonical_json_bytes(value: Any) -> bytes:
"""Stable JSON serialization for hashing: sorted keys + no whitespace."""
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
"utf-8"
)


def apply_verify_echo(*, request: Request, parsed_body: Optional[Any]) -> Dict[str, Any]:
def apply_verify_echo(*, request: Request, body_text: Optional[str]) -> Dict[str, Any]:
"""Return verify-echo fields to merge into the response.

Returns ``{}`` when the header is absent (zero-cost no-op for non-opted
clients). When present, returns::

{
"body_received": <parsed body dict / str / None>,
"body_received": <body_text — str or None>,
"body_received_sha256": <64-char hex digest>,
}

Hashing rule:

* ``None`` body → SHA256 of empty bytes (well-known constant).
* Pydantic model → ``model_dump(mode="json")`` then canonical JSON.
* dict → canonical JSON.
* Otherwise → ``str()`` then UTF-8 bytes.

The dict is intended to be ``.update()``-merged into the response dict
or returned alongside other fields. Caller is responsible for placement.
* ``None`` body → SHA256 of empty bytes (well-known constant); ``body_received``
is ``None``.
* Otherwise → caller passes the EXACT string they want echoed (typically the
``body`` field of a message or the user-content field of a fire payload);
``body_received`` is that string verbatim and the hash is over its UTF-8
bytes.

Caller-side verification recipe (mirrors what cueapi-python's auto-verify
does)::

import hashlib
sent = "..." # the exact string you POSTed in the body field
resp = client.post(..., json={"body": sent},
headers={"X-CueAPI-Verify-Echo": "true"})
assert resp.json()["body_received"] == sent
assert resp.json()["body_received_sha256"] == hashlib.sha256(
sent.encode("utf-8")
).hexdigest()
"""
if not verify_echo_requested(request):
return {}

if parsed_body is None:
body_view: Any = None
sha_input: bytes = b""
elif hasattr(parsed_body, "model_dump"):
body_view = parsed_body.model_dump(mode="json")
sha_input = _canonical_json_bytes(body_view)
elif isinstance(parsed_body, dict):
body_view = parsed_body
sha_input = _canonical_json_bytes(body_view)
else:
body_view = str(parsed_body)
sha_input = body_view.encode("utf-8")
if body_text is None:
return {
"body_received": None,
"body_received_sha256": hashlib.sha256(b"").hexdigest(),
}

return {
"body_received": body_view,
"body_received_sha256": hashlib.sha256(sha_input).hexdigest(),
"body_received": body_text,
"body_received_sha256": hashlib.sha256(body_text.encode("utf-8")).hexdigest(),
}
Loading