From d4be0ed2fb71620f7f8b619132ba109a7af394dc Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 11 May 2026 15:37:25 -0700 Subject: [PATCH] messages send + message-to: Layer 3 force-file mode (body-verify defense) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mike body-verify directive 2026-05-11 — close the caller-side shell- expansion bug class on Cue Message outbound. Design Dock workspace: cue-message-silent-corruption-substrate-design-2026-05-11. Empirical bug class: BODY="...$(echo X)..." assignment command- substitutes at variable-assignment time, BEFORE the CLI receives the arg. Server accepts the (mutated) POST with HTTP 200; recipient sees corrupted content. Send-helper shell-safety (json.dumps) does NOT protect against this — the mutation is upstream. Changes: cueapi/cli.py — new _acquire_message_body() helper applied to both messages_send + message_to commands. Body comes from exactly ONE of: --message-file RECOMMENDED for content with metachars; zero shell interpolation; reads body from the given path byte-identical. --body-stdin read body from stdin (shell-pipe ergonomics: `echo X | cueapi messages send --body-stdin --from ... --to ...`). --body inline; auto-rejected when content contains $(...), `...`, or ${VAR}. Override via --allow-inline-metachars for legitimate literal-metachar content. Rejection error gives 3 actionable mitigations. Multiple sources provided also rejected (exactly one required). tests/test_cli.py — 9 new tests pin the invariants: - Reject inline $(...) / backticks / ${VAR} - Accept inline when metachar-free - Accept inline with --allow-inline-metachars override - Accept --message-file (byte-identical incl. metachars) - Accept --body-stdin (byte-identical) - Reject multiple body sources - message-to parity (same Layer 3 guard) All 219 tests pass (45 existing messages tests + 9 new + 165 others). Existing API contract preserved: `--from` and `--to` still required; `--body` still works for metachar-free content (the common interactive ad-hoc case). New options are additive. The only breaking change is: inline `--body` with $(...)/backticks/${VAR} now exit-1 with actionable error instead of silently sending mutated content. That's the intended Layer 3 behavior. CHANGELOG entry under [Unreleased]. Phase 3 of body-verify defense-in-depth. Layer 2 (auto-verify via X-CueAPI-Verify-Echo) ships after Layer 1 substrate header lands (cueapi-primary's lane, ~1-3d). Layer 4 docs joint with cue-pm. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + cueapi/cli.py | 175 ++++++++++++++++++++++++++++++++++++++++++++-- tests/test_cli.py | 155 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 324 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e141f43..31a0636 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`: 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`. - `agents describe ` alias for `agents get `. diff --git a/cueapi/cli.py b/cueapi/cli.py index ef5129e..4648903 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -2,6 +2,8 @@ from __future__ import annotations import json +import re +import sys import webbrowser from typing import Optional @@ -1873,6 +1875,80 @@ def workers_delete(ctx: click.Context, worker_id: str, yes: bool) -> None: # in the sibling `cueapi agents` command group (separate PR). +# --------------------------------------------------------------------------- +# Body-source acquisition with shell-expansion guard +# (Phase 3 of body-verify defense-in-depth, Mike directive 2026-05-11.) +# +# Empirical bug class: caller-side shell expansion of $(...) / backticks / +# ${VAR} in body args BEFORE the CLI receives them silently mutates body +# content. Server accepts the (mutated) POST with HTTP 200; recipient sees +# corrupted content. Send-helper shell-safety (json.dumps) does NOT +# protect against this — the mutation is upstream. +# +# Mitigation: force-file-by-default. Inline --body accepted ONLY when +# auto-detected metachar-free; otherwise user gets actionable error with +# safer alternatives (--message-file, --body-stdin). +# --------------------------------------------------------------------------- + +# Conservative regex — false-positives (legitimate literal metachars) are +# acceptable cost; user overrides via --allow-inline-metachars. Catches +# the three classes that command-substitute or expand in bash/zsh. +_BODY_METACHAR_RE = re.compile(r"\$\(|`|\$\{") + + +def _acquire_message_body( + body_text: Optional[str], + message_file: Optional[str], + body_stdin: bool, + allow_inline_metachars: bool, +) -> str: + """Resolve message body from exactly one of 3 sources (file / stdin / + inline). Enforce force-file-by-default Layer 3 guard on inline path. + """ + sources_count = sum([ + message_file is not None, + body_stdin, + body_text is not None, + ]) + if sources_count == 0: + raise click.UsageError( + "Provide message body via one of:\n" + " --message-file (RECOMMENDED — zero shell interpolation)\n" + " --body-stdin (pipe body via stdin)\n" + " --body '' (inline; rejected if contains $(...), `...`, ${VAR})" + ) + if sources_count > 1: + raise click.UsageError( + "Multiple body sources provided — pick exactly one of " + "--message-file / --body-stdin / --body." + ) + + if message_file is not None: + try: + with open(message_file, "r", encoding="utf-8") as fp: + return fp.read() + except OSError as e: + raise click.UsageError(f"--message-file path unreadable: {e}") + + if body_stdin: + return sys.stdin.read() + + # Inline path — Layer 3 metachar guard. + assert body_text is not None + if not allow_inline_metachars and _BODY_METACHAR_RE.search(body_text): + raise click.UsageError( + "Inline --body contains shell metacharacters that may have been\n" + "expanded by your shell BEFORE cueapi-cli received the arg.\n" + "Detected one or more of: $(...), `...`, ${VAR}\n" + "\n" + "Safe alternatives:\n" + " --message-file (RECOMMENDED — zero interpolation)\n" + " --body-stdin (pipe content from stdin)\n" + " --allow-inline-metachars (override; you confirm body is literal)" + ) + return body_text + + @main.group() def messages() -> None: """Send and manage messages (messaging primitive: per-message lifecycle).""" @@ -1894,7 +1970,52 @@ def messages() -> None: required=True, help="Recipient — opaque agent_id or slug-form (agent@user).", ) -@click.option("--body", "body_text", required=True, help="Message body (1-32768 chars)") +@click.option( + "--body", + "body_text", + default=None, + help=( + "Message body (1-32768 chars). Inline; auto-rejected if it " + "contains shell metacharacters ($(...), `...`, ${VAR}) — use " + "--message-file or --body-stdin for those, or --allow-inline-metachars " + "to override. Provide exactly one of --body / --message-file / --body-stdin." + ), +) +@click.option( + "--message-file", + "message_file", + default=None, + type=click.Path(exists=True, dir_okay=False, readable=True), + help=( + "RECOMMENDED for content with shell metacharacters. Reads body " + "from the given path (zero shell interpolation). Mint pattern: " + "heredoc with single-quoted EOF marker → file → pass path." + ), +) +@click.option( + "--body-stdin", + "body_stdin", + is_flag=True, + default=False, + help=( + "Read message body from stdin. Use for shell-pipe ergonomics: " + "`echo 'hi' | cueapi messages send --body-stdin --from ... --to ...`. " + "Caller's shell still expands the producer side (e.g., echo arg) " + "but the pipe shape is well-understood." + ), +) +@click.option( + "--allow-inline-metachars", + "allow_inline_metachars", + is_flag=True, + default=False, + help=( + "Override the Layer 3 force-file guard. Use ONLY when you've " + "verified the inline --body content is byte-identical to your " + "intent (e.g., shell-tutorial example legitimately containing " + "literal $(...) text). Otherwise prefer --message-file." + ), +) @click.option("--subject", default=None, help="Optional subject line (max 255 chars)") @click.option( "--reply-to", @@ -1979,7 +2100,10 @@ def messages_send( ctx: click.Context, from_agent: str, to: str, - body_text: str, + body_text: Optional[str], + message_file: Optional[str], + body_stdin: bool, + allow_inline_metachars: bool, subject: Optional[str], reply_to: Optional[str], priority: Optional[int], @@ -1992,7 +2116,10 @@ def messages_send( mode: str, ) -> None: """Send a message.""" - body: dict = {"to": to, "body": body_text} + resolved_body = _acquire_message_body( + body_text, message_file, body_stdin, allow_inline_metachars + ) + body: dict = {"to": to, "body": resolved_body} if subject: body["subject"] = subject if reply_to: @@ -2248,7 +2375,37 @@ def _resolve_recipient(client, recipient: str) -> str: "the X-Cueapi-From-Agent header." ), ) -@click.option("--body", "body_text", required=True, help="Message body (1-32768 chars)") +@click.option( + "--body", + "body_text", + default=None, + help=( + "Message body inline; auto-rejected if it contains shell " + "metacharacters. Use --message-file or --body-stdin for content " + "with $(...) / backticks / ${VAR}." + ), +) +@click.option( + "--message-file", + "message_file", + default=None, + type=click.Path(exists=True, dir_okay=False, readable=True), + help="RECOMMENDED: read body from file (zero shell interpolation).", +) +@click.option( + "--body-stdin", + "body_stdin", + is_flag=True, + default=False, + help="Read body from stdin (shell-pipe ergonomics).", +) +@click.option( + "--allow-inline-metachars", + "allow_inline_metachars", + is_flag=True, + default=False, + help="Override the Layer 3 force-file guard for legitimate literal-metachar inline content.", +) @click.option("--subject", default=None, help="Optional subject line (max 255 chars)") @click.option( "--reply-to", @@ -2330,7 +2487,10 @@ def message_to( ctx: click.Context, recipient: str, from_agent: str, - body_text: str, + body_text: Optional[str], + message_file: Optional[str], + body_stdin: bool, + allow_inline_metachars: bool, subject: Optional[str], reply_to: Optional[str], priority: Optional[int], @@ -2348,7 +2508,10 @@ def message_to( agent_id (agt_*) or slug-form (slug@user) — used as-is. bare name — matched case-insensitive against display_name and slug. """ - body: dict = {"body": body_text} + resolved_body = _acquire_message_body( + body_text, message_file, body_stdin, allow_inline_metachars + ) + body: dict = {"body": resolved_body} if subject: body["subject"] = subject if reply_to: diff --git a/tests/test_cli.py b/tests/test_cli.py index 0b8cfba..4bc6253 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1888,11 +1888,164 @@ def test_messages_send_requires_from_and_to_and_body(): # Missing --to r2 = runner.invoke(main, ["messages", "send", "--from", "x", "--body", "hi"]) assert r2.exit_code != 0 - # Missing --body + # Missing body source entirely (post-Phase-3: not just --body; any of + # --message-file / --body-stdin / --body works) r3 = runner.invoke(main, ["messages", "send", "--from", "x", "--to", "y"]) assert r3.exit_code != 0 +# --- Phase 3: body-source acquisition (force-file mode) --- + + +def test_messages_send_rejects_inline_body_with_dollar_paren(): + """Layer 3 force-file guard: $(...) in inline body must be rejected.""" + r = runner.invoke( + main, + ["messages", "send", "--from", "a@x", "--to", "b@y", + "--body", "body with $(echo INJECT) embedded"], + ) + assert r.exit_code != 0 + assert "shell metacharacters" in r.output + + +def test_messages_send_rejects_inline_body_with_backticks(): + """Layer 3 force-file guard: backticks in inline body must be rejected.""" + r = runner.invoke( + main, + ["messages", "send", "--from", "a@x", "--to", "b@y", + "--body", "body with `echo INJECT` embedded"], + ) + assert r.exit_code != 0 + assert "shell metacharacters" in r.output + + +def test_messages_send_rejects_inline_body_with_dollar_brace(): + """Layer 3 force-file guard: ${VAR} in inline body must be rejected.""" + r = runner.invoke( + main, + ["messages", "send", "--from", "a@x", "--to", "b@y", + "--body", "body with ${VAR_INJECT} embedded"], + ) + assert r.exit_code != 0 + assert "shell metacharacters" in r.output + + +def test_messages_send_accepts_inline_body_when_metachar_free(monkeypatch): + """Inline --body without metachars is accepted byte-identical.""" + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_safe", "delivery_state": "queued", "thread_id": "thr_x"}, + ) + }, + ) + r = runner.invoke( + main, + ["messages", "send", "--from", "a@x", "--to", "b@y", + "--body", "plain prose body without any metachars"], + ) + assert r.exit_code == 0, r.output + _, _, body, _ = holder["client"].calls[-1] + assert body["body"] == "plain prose body without any metachars" + + +def test_messages_send_accepts_inline_metachars_with_override(monkeypatch): + """--allow-inline-metachars override accepts inline body with $(...) etc.""" + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_override", "delivery_state": "queued", "thread_id": "thr_x"}, + ) + }, + ) + r = runner.invoke( + main, + ["messages", "send", "--from", "a@x", "--to", "b@y", + "--body", "body with $(echo LITERAL) intended verbatim", + "--allow-inline-metachars"], + ) + assert r.exit_code == 0, r.output + _, _, body, _ = holder["client"].calls[-1] + assert body["body"] == "body with $(echo LITERAL) intended verbatim" + + +def test_messages_send_accepts_message_file(monkeypatch, tmp_path): + """--message-file reads body from path byte-identical (incl. metachars).""" + body_file = tmp_path / "body.txt" + body_text = "metachar-rich body: $(echo X) and `echo Y` and ${HOME}" + body_file.write_text(body_text) + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_file", "delivery_state": "queued", "thread_id": "thr_x"}, + ) + }, + ) + r = runner.invoke( + main, + ["messages", "send", "--from", "a@x", "--to", "b@y", + "--message-file", str(body_file)], + ) + assert r.exit_code == 0, r.output + _, _, body, _ = holder["client"].calls[-1] + assert body["body"] == body_text + + +def test_messages_send_accepts_body_stdin(monkeypatch): + """--body-stdin reads body from stdin byte-identical.""" + body_text = "stdin body with $(echo metachars) preserved" + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_stdin", "delivery_state": "queued", "thread_id": "thr_x"}, + ) + }, + ) + r = runner.invoke( + main, + ["messages", "send", "--from", "a@x", "--to", "b@y", "--body-stdin"], + input=body_text, + ) + assert r.exit_code == 0, r.output + _, _, body, _ = holder["client"].calls[-1] + assert body["body"] == body_text + + +def test_messages_send_rejects_multiple_body_sources(): + """Exactly one of --body / --message-file / --body-stdin must be set.""" + r = runner.invoke( + main, + ["messages", "send", "--from", "a@x", "--to", "b@y", + "--body", "inline body", "--body-stdin"], + input="stdin body", + ) + assert r.exit_code != 0 + assert "Multiple body sources" in r.output + + +def test_message_to_rejects_inline_body_with_metachars(): + """Parity: legacy `message-to` command applies same Layer 3 guard.""" + r = runner.invoke( + main, + ["message-to", "recipient@y", "--from", "a@x", + "--body", "body with $(echo INJECT)"], + ) + assert r.exit_code != 0 + assert "shell metacharacters" in r.output + + # --- send body + headers ---