Skip to content

Commit 4795285

Browse files
mikemolinetclaude
andauthored
messages send + message-to: Layer 3 force-file mode (body-verify defense) (#51)
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 <path> 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> 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) <noreply@anthropic.com>
1 parent 2bd30a2 commit 4795285

3 files changed

Lines changed: 324 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ All notable changes to cueapi-cli will be documented here.
55
## [Unreleased]
66

77
### Added
8+
- **`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 <path>` (RECOMMENDED for content with shell metacharacters; zero shell interpolation), `--body-stdin` (read from stdin; for shell-pipe ergonomics), or `--body <inline>` (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`.
89
- `cueapi message-to <recipient>` top-level wrapper for sending a message by name. Resolves `<recipient>` 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`).
910
- `agents list --online-only` shortcut for `--status online`. Mutually exclusive with `--status`.
1011
- `agents describe <ref>` alias for `agents get <ref>`.

cueapi/cli.py

Lines changed: 169 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from __future__ import annotations
33

44
import json
5+
import re
6+
import sys
57
import webbrowser
68
from typing import Optional
79

@@ -1873,6 +1875,80 @@ def workers_delete(ctx: click.Context, worker_id: str, yes: bool) -> None:
18731875
# in the sibling `cueapi agents` command group (separate PR).
18741876

18751877

1878+
# ---------------------------------------------------------------------------
1879+
# Body-source acquisition with shell-expansion guard
1880+
# (Phase 3 of body-verify defense-in-depth, Mike directive 2026-05-11.)
1881+
#
1882+
# Empirical bug class: caller-side shell expansion of $(...) / backticks /
1883+
# ${VAR} in body args BEFORE the CLI receives them silently mutates body
1884+
# content. Server accepts the (mutated) POST with HTTP 200; recipient sees
1885+
# corrupted content. Send-helper shell-safety (json.dumps) does NOT
1886+
# protect against this — the mutation is upstream.
1887+
#
1888+
# Mitigation: force-file-by-default. Inline --body accepted ONLY when
1889+
# auto-detected metachar-free; otherwise user gets actionable error with
1890+
# safer alternatives (--message-file, --body-stdin).
1891+
# ---------------------------------------------------------------------------
1892+
1893+
# Conservative regex — false-positives (legitimate literal metachars) are
1894+
# acceptable cost; user overrides via --allow-inline-metachars. Catches
1895+
# the three classes that command-substitute or expand in bash/zsh.
1896+
_BODY_METACHAR_RE = re.compile(r"\$\(|`|\$\{")
1897+
1898+
1899+
def _acquire_message_body(
1900+
body_text: Optional[str],
1901+
message_file: Optional[str],
1902+
body_stdin: bool,
1903+
allow_inline_metachars: bool,
1904+
) -> str:
1905+
"""Resolve message body from exactly one of 3 sources (file / stdin /
1906+
inline). Enforce force-file-by-default Layer 3 guard on inline path.
1907+
"""
1908+
sources_count = sum([
1909+
message_file is not None,
1910+
body_stdin,
1911+
body_text is not None,
1912+
])
1913+
if sources_count == 0:
1914+
raise click.UsageError(
1915+
"Provide message body via one of:\n"
1916+
" --message-file <path> (RECOMMENDED — zero shell interpolation)\n"
1917+
" --body-stdin (pipe body via stdin)\n"
1918+
" --body '<text>' (inline; rejected if contains $(...), `...`, ${VAR})"
1919+
)
1920+
if sources_count > 1:
1921+
raise click.UsageError(
1922+
"Multiple body sources provided — pick exactly one of "
1923+
"--message-file / --body-stdin / --body."
1924+
)
1925+
1926+
if message_file is not None:
1927+
try:
1928+
with open(message_file, "r", encoding="utf-8") as fp:
1929+
return fp.read()
1930+
except OSError as e:
1931+
raise click.UsageError(f"--message-file path unreadable: {e}")
1932+
1933+
if body_stdin:
1934+
return sys.stdin.read()
1935+
1936+
# Inline path — Layer 3 metachar guard.
1937+
assert body_text is not None
1938+
if not allow_inline_metachars and _BODY_METACHAR_RE.search(body_text):
1939+
raise click.UsageError(
1940+
"Inline --body contains shell metacharacters that may have been\n"
1941+
"expanded by your shell BEFORE cueapi-cli received the arg.\n"
1942+
"Detected one or more of: $(...), `...`, ${VAR}\n"
1943+
"\n"
1944+
"Safe alternatives:\n"
1945+
" --message-file <path> (RECOMMENDED — zero interpolation)\n"
1946+
" --body-stdin (pipe content from stdin)\n"
1947+
" --allow-inline-metachars (override; you confirm body is literal)"
1948+
)
1949+
return body_text
1950+
1951+
18761952
@main.group()
18771953
def messages() -> None:
18781954
"""Send and manage messages (messaging primitive: per-message lifecycle)."""
@@ -1894,7 +1970,52 @@ def messages() -> None:
18941970
required=True,
18951971
help="Recipient — opaque agent_id or slug-form (agent@user).",
18961972
)
1897-
@click.option("--body", "body_text", required=True, help="Message body (1-32768 chars)")
1973+
@click.option(
1974+
"--body",
1975+
"body_text",
1976+
default=None,
1977+
help=(
1978+
"Message body (1-32768 chars). Inline; auto-rejected if it "
1979+
"contains shell metacharacters ($(...), `...`, ${VAR}) — use "
1980+
"--message-file or --body-stdin for those, or --allow-inline-metachars "
1981+
"to override. Provide exactly one of --body / --message-file / --body-stdin."
1982+
),
1983+
)
1984+
@click.option(
1985+
"--message-file",
1986+
"message_file",
1987+
default=None,
1988+
type=click.Path(exists=True, dir_okay=False, readable=True),
1989+
help=(
1990+
"RECOMMENDED for content with shell metacharacters. Reads body "
1991+
"from the given path (zero shell interpolation). Mint pattern: "
1992+
"heredoc with single-quoted EOF marker → file → pass path."
1993+
),
1994+
)
1995+
@click.option(
1996+
"--body-stdin",
1997+
"body_stdin",
1998+
is_flag=True,
1999+
default=False,
2000+
help=(
2001+
"Read message body from stdin. Use for shell-pipe ergonomics: "
2002+
"`echo 'hi' | cueapi messages send --body-stdin --from ... --to ...`. "
2003+
"Caller's shell still expands the producer side (e.g., echo arg) "
2004+
"but the pipe shape is well-understood."
2005+
),
2006+
)
2007+
@click.option(
2008+
"--allow-inline-metachars",
2009+
"allow_inline_metachars",
2010+
is_flag=True,
2011+
default=False,
2012+
help=(
2013+
"Override the Layer 3 force-file guard. Use ONLY when you've "
2014+
"verified the inline --body content is byte-identical to your "
2015+
"intent (e.g., shell-tutorial example legitimately containing "
2016+
"literal $(...) text). Otherwise prefer --message-file."
2017+
),
2018+
)
18982019
@click.option("--subject", default=None, help="Optional subject line (max 255 chars)")
18992020
@click.option(
19002021
"--reply-to",
@@ -1979,7 +2100,10 @@ def messages_send(
19792100
ctx: click.Context,
19802101
from_agent: str,
19812102
to: str,
1982-
body_text: str,
2103+
body_text: Optional[str],
2104+
message_file: Optional[str],
2105+
body_stdin: bool,
2106+
allow_inline_metachars: bool,
19832107
subject: Optional[str],
19842108
reply_to: Optional[str],
19852109
priority: Optional[int],
@@ -1992,7 +2116,10 @@ def messages_send(
19922116
mode: str,
19932117
) -> None:
19942118
"""Send a message."""
1995-
body: dict = {"to": to, "body": body_text}
2119+
resolved_body = _acquire_message_body(
2120+
body_text, message_file, body_stdin, allow_inline_metachars
2121+
)
2122+
body: dict = {"to": to, "body": resolved_body}
19962123
if subject:
19972124
body["subject"] = subject
19982125
if reply_to:
@@ -2248,7 +2375,37 @@ def _resolve_recipient(client, recipient: str) -> str:
22482375
"the X-Cueapi-From-Agent header."
22492376
),
22502377
)
2251-
@click.option("--body", "body_text", required=True, help="Message body (1-32768 chars)")
2378+
@click.option(
2379+
"--body",
2380+
"body_text",
2381+
default=None,
2382+
help=(
2383+
"Message body inline; auto-rejected if it contains shell "
2384+
"metacharacters. Use --message-file or --body-stdin for content "
2385+
"with $(...) / backticks / ${VAR}."
2386+
),
2387+
)
2388+
@click.option(
2389+
"--message-file",
2390+
"message_file",
2391+
default=None,
2392+
type=click.Path(exists=True, dir_okay=False, readable=True),
2393+
help="RECOMMENDED: read body from file (zero shell interpolation).",
2394+
)
2395+
@click.option(
2396+
"--body-stdin",
2397+
"body_stdin",
2398+
is_flag=True,
2399+
default=False,
2400+
help="Read body from stdin (shell-pipe ergonomics).",
2401+
)
2402+
@click.option(
2403+
"--allow-inline-metachars",
2404+
"allow_inline_metachars",
2405+
is_flag=True,
2406+
default=False,
2407+
help="Override the Layer 3 force-file guard for legitimate literal-metachar inline content.",
2408+
)
22522409
@click.option("--subject", default=None, help="Optional subject line (max 255 chars)")
22532410
@click.option(
22542411
"--reply-to",
@@ -2330,7 +2487,10 @@ def message_to(
23302487
ctx: click.Context,
23312488
recipient: str,
23322489
from_agent: str,
2333-
body_text: str,
2490+
body_text: Optional[str],
2491+
message_file: Optional[str],
2492+
body_stdin: bool,
2493+
allow_inline_metachars: bool,
23342494
subject: Optional[str],
23352495
reply_to: Optional[str],
23362496
priority: Optional[int],
@@ -2348,7 +2508,10 @@ def message_to(
23482508
agent_id (agt_*) or slug-form (slug@user) — used as-is.
23492509
bare name — matched case-insensitive against display_name and slug.
23502510
"""
2351-
body: dict = {"body": body_text}
2511+
resolved_body = _acquire_message_body(
2512+
body_text, message_file, body_stdin, allow_inline_metachars
2513+
)
2514+
body: dict = {"body": resolved_body}
23522515
if subject:
23532516
body["subject"] = subject
23542517
if reply_to:

0 commit comments

Comments
 (0)