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
40 changes: 40 additions & 0 deletions changelog.d/DIVE-4421.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## Unreleased — fix(agent): `agent send`/`ask` teach the verbatim body form FIRST, and take it on stdin (DIVE-4421)

A customer reported two agents corrupting a2a messages independently within two hours — one of
them *after* reading a written warning about it. A backtick-quoted CLI verb vanished (it ran as
command substitution, as the sender); `US$4,500` arrived as `US,500` (`$4` is an empty
positional). Neither sender saw an error. Both sends printed OK. The corruption happens in the
CALLER'S SHELL, before this CLI is executed, so no amount of care on the receiving side can see it.

**The safe form already existed and nobody found it.** `--message-file=<path>` has read the body
verbatim since DIVE-2627; its warning lived in a parser comment, and the usage line read
`send <name> <text...>|--message=<text>|--message-file=<path>` — the two forms your shell rewrites
first, the one it cannot rewrite last, and nothing at the point of typing saying why the order
matters. So this is a discoverability defect, not a missing capability, and the fix is on the tool.

- **The usage leads with the safe form**, and says what the other two do to your text:
`--message-file=<path>|--message-file=-|--message=<text>|<text...>`, annotated with the two
reported instances rather than an abstraction. `5dive agent send --help` and
`agent ask --help` now render it instead of dying on `unknown flag: --help` — the one place a
caller asks the tool how to pass a body used to answer with an error.
- **`--message-file=-` reads the body from stdin**, so a quoted heredoc needs no temp file. The
branch is in `_read_prose_file`, so every `*-file` prose flag in the CLI gains it at once, and
the stdin read is the same `read -r -d ''` as the file read — the two cannot drift into eating
different bytes. Empty stdin is refused exactly as an empty file is.
- **`agent ask` gets `--message-file` too.** It shared `--message=` with `send` and had no file
form at all, so teaching "use the file form" there would have been a lie — and an ask body (a
question with code in it) is typically the longer one.
- **A send-side nudge, and it is deliberately not a detector.** When a body supplied inline still
contains a backtick or `$`, one line names `--message-file`. By then the shell has already run
what it was going to run, so the CORRUPTED bodies arrive with the hazard gone and this cannot
see them; what it sees is a caller hand-assembling a body with metacharacters in it, which is
the moment worth interrupting. The receive-side "empty expansion artifact" warning that was
also requested is **declined**: it is a by-name list of artifacts — the same incomplete-list
trap the report names for "just escape it" — and `US,500` is a legal string somebody may mean.

Graded by `tests/agent_send_verbatim_unit.sh` (25 arms): the RENDERED `--help` — not the source,
because the usage block is an unquoted heredoc and the first draft of this very fix had its own
backticks eaten by command substitution — plus a byte-identical stdin round trip through
`cmd_send` carrying all four hazard classes at once, a mutation arm that cuts the stdin branch out
of the shipping function's own text, the DIVE-2627 refusals unchanged, and the nudge's silence on
an already-corrupted body.
56 changes: 53 additions & 3 deletions src/cmd_agent_runtime.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2446,6 +2446,31 @@ _agent_send_row_hint() {
return 0
}

# _agent_body_shell_hint <message> <msg_src> — DIVE-4421. One line, stderr, always 0.
#
# A REMINDER, NOT A DETECTOR, and the difference is the whole design. By the time
# argv reaches this process the caller's shell has ALREADY run `cmd`, expanded
# $VAR and eaten $4 — the CORRUPTED bodies arrive with the hazard removed
# ("US$4,500" is already "US,500"), so nothing on this side can see them. What
# survives to argv is a backtick or $ the caller successfully QUOTED, i.e. the
# calls that worked. So this fires on the safe ones and is silent on the broken
# ones, on purpose: it has no false positives on a body that is fine to send, and
# it fires at the one moment we know the caller is hand-assembling a body with
# shell metacharacters in it — which is the moment to name the form that cannot
# be corrupted. It is not, and must not be read as, a corruption check.
#
# The receive-side "empty expansion artifact" warn (the reporter's request 2) was
# DECLINED for the mirror-image reason, recorded on DIVE-4421: it is a by-name
# list of artifacts — the same incomplete-list trap the report itself names for
# "just escape it" — and "US,500" is a legal string a sender may have meant.
_agent_body_shell_hint() {
local msg="${1:-}" src="${2:-}"
[[ "$src" != "--message-file" ]] || return 0
[[ "$msg" == *'`'* || "$msg" == *'$'* ]] || return 0
warn "this body still carries a backtick or \$ — your shell expanded any UNQUOTED \$VAR, \`cmd\` or \$(cmd) BEFORE 5dive saw it, with no error on either side. For code, currency, paths or quotes send the body verbatim instead: --message-file=<path>, or --message-file=- fed by a QUOTED heredoc. See: 5dive agent send --help"
return 0
}

cmd_send() {
local name="" message="" from="" from_set=0 raw=0 wake=0
local reply_to_chat="" reply_to_msg=""
Expand All @@ -2467,6 +2492,13 @@ cmd_send() {
--wake) wake=1 ;;
--reply-to-chat=*) reply_to_chat="${1#--reply-to-chat=}" ;;
--reply-to-msg=*) reply_to_msg="${1#--reply-to-msg=}" ;;
# DIVE-4421: `5dive agent send --help` used to die on `unknown flag: --help`,
# so the one place a caller asks the tool how to pass a body answered with an
# error. It renders the SHARED usage() rather than a second copy of the send
# block: a duplicated help text is a text that drifts, and the whole defect
# this row fixes is a warning that was reachable in one place and absent in
# the place people type.
-h|--help) usage; exit 0 ;;
--) shift; positional+=("$@"); break ;;
-*) fail "$E_USAGE" "unknown flag: $1" ;;
*) positional+=("$1") ;;
Expand All @@ -2477,7 +2509,7 @@ cmd_send() {
name="${positional[0]}"
positional=("${positional[@]:1}")
fi
[[ -n "$name" ]] || fail "$E_USAGE" "usage: 5dive agent send <name> <text...> | --message=<text> | --message-file=<path> [--from=<sender>] [--raw] [--wake] [--reply-to-chat=<id> [--reply-to-msg=<id>]]"
[[ -n "$name" ]] || fail "$E_USAGE" "usage: 5dive agent send <name> --message-file=<path>|--message-file=- (VERBATIM — your shell cannot corrupt it) | --message=<text> | <text...> [--from=<sender>] [--raw] [--wake] [--reply-to-chat=<id> [--reply-to-msg=<id>]]"
# DIVE-2627: positional text is the third way to supply the body. With
# --message-file set, the `-z "$message"` guard below would DROP trailing
# positional words without a word about it, so refuse instead. Deliberately
Expand All @@ -2490,6 +2522,10 @@ cmd_send() {
message="${positional[*]}"
fi
[[ -n "$message" ]] || fail "$E_USAGE" "message is empty"
# DIVE-4421. Positional text went through the caller's shell exactly as
# --message= did, so msg_src being empty (the positional form) is hinted too —
# only --message-file is exempt.
_agent_body_shell_hint "$message" "$msg_src"

# DIVE-1065: a standard-isolation agent has no broad sudo, so it cannot run the
# direct `sudo -u agent-X tmux` inject this function uses below. Route a
Expand Down Expand Up @@ -2769,6 +2805,11 @@ cmd_send() {
# will keep us awake until --timeout — that's correct behaviour.
cmd_ask() {
local name="" message="" from="" from_set=0
# DIVE-4421: same body-source bookkeeping as cmd_send. `ask` shared `--message=`
# with `send` and had NO file form at all, so the safe form the usage now teaches
# first would have been a lie on this verb — the corruption is identical here,
# and an ask body is typically LONGER (a question with code in it) than a send.
local msg_src=""
local reply_to_chat="" reply_to_msg=""
local timeout=120 idle=5 poll=2 buf_lines=2000 allow_unfenced=0
# DIVE-3388 arm 2: how long to give the injected question to ECHO into the pane
Expand All @@ -2779,7 +2820,11 @@ cmd_ask() {
local -a positional=()
while [[ $# -gt 0 ]]; do
case "$1" in
--message=*) message="${1#--message=}" ;;
--message=*) _prose_flag_dupe --message "$msg_src"; message="${1#--message=}"; msg_src="--message" ;;
--message-file=*) _prose_flag_dupe --message-file "$msg_src"
_read_prose_file --message-file "${1#--message-file=}"
message="$_PROSE_FILE_VALUE"; msg_src="--message-file" ;;
-h|--help) usage; exit 0 ;;
--from=*) from="${1#--from=}"; from_set=1 ;;
--reply-to-chat=*) reply_to_chat="${1#--reply-to-chat=}" ;;
--reply-to-msg=*) reply_to_msg="${1#--reply-to-msg=}" ;;
Expand All @@ -2799,7 +2844,7 @@ cmd_ask() {
name="${positional[0]}"
positional=("${positional[@]:1}")
fi
[[ -n "$name" ]] || fail "$E_USAGE" "usage: 5dive agent ask <name> <text...> [--from=<sender>] [--reply-to-chat=<id> [--reply-to-msg=<id>]] [--timeout=120] [--idle-secs=5] [--poll-secs=2] [--deliver-secs=30] [--allow-unfenced]"
[[ -n "$name" ]] || fail "$E_USAGE" "usage: 5dive agent ask <name> --message-file=<path>|--message-file=- (VERBATIM — your shell cannot corrupt it) | --message=<text> | <text...> [--from=<sender>] [--reply-to-chat=<id> [--reply-to-msg=<id>]] [--timeout=120] [--idle-secs=5] [--poll-secs=2] [--deliver-secs=30] [--allow-unfenced]"
# DIVE-1901: --allow-unfenced re-enables pane scraping, which is the path that
# has fabricated every bad reply this ticket has caught. It exists for a seat
# that genuinely cannot follow the reply-format instruction — never for a
Expand All @@ -2812,10 +2857,15 @@ cmd_ask() {
if (( allow_unfenced )) && [[ "$from" == council* ]]; then
fail "$E_VALIDATION" "--allow-unfenced is refused on a council ask — a seat that cannot fence records as a CAPTURE FAILURE, not an abstain"
fi
# DIVE-4421: identical to cmd_send's guard — with --message-file set, the
# `-z "$message"` test below would DROP trailing positional words silently.
[[ "$msg_src" != "--message-file" || ${#positional[@]} -eq 0 ]] \
|| fail "$E_USAGE" "--message-file conflicts with the positional text — pass the body exactly once, either inline or from a file."
if [[ -z "$message" && ${#positional[@]} -gt 0 ]]; then
message="${positional[*]}"
fi
[[ -n "$message" ]] || fail "$E_USAGE" "message is empty"
_agent_body_shell_hint "$message" "$msg_src"
for n in "$timeout" "$idle" "$poll" "$buf_lines" "$deliver_grace"; do
[[ "$n" =~ ^[0-9]+$ ]] || fail "$E_VALIDATION" "timeout/idle/poll/buffer-lines/deliver-secs must be non-negative integers"
done
Expand Down
13 changes: 13 additions & 0 deletions src/lib/validation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,23 @@ require_root() {
# Refuses empty rather than recording it: every caller of this treats empty as
# "flag not given", so a silently-empty file would land the exact same
# indistinguishable-from-correct write the argv form does.
#
# DIVE-4421: `-` means STDIN, so a caller with a heredoc needs no temp file:
# 5dive agent send dev --message-file=- <<'EOF'
# The read is the SAME `read -r -d ''` as the file path below, so the two forms
# cannot drift into eating different bytes. There is no ambiguity to protect
# against: a file literally named `-` is addressable as `./-`, and every other
# flag in this CLI that takes `-` (--telegram-token) already spells it this way.
_read_prose_file() {
local flag="$1" path="$2"
_PROSE_FILE_VALUE=""
[[ -n "$path" ]] || fail "$E_USAGE" "$flag needs a path: ${flag}=<file>"
if [[ "$path" == "-" ]]; then
IFS= read -r -d '' _PROSE_FILE_VALUE || true
[[ -n "$_PROSE_FILE_VALUE" ]] \
|| fail "$E_VALIDATION" "$flag: stdin was empty — refusing to record an empty value (an empty stdin is indistinguishable from the flag never being passed, which is the failure mode this flag exists to remove)"
return 0
fi
[[ -e "$path" ]] || fail "$E_USAGE" "$flag: no such file '$path'"
[[ -f "$path" || -p "$path" || -c "$path" ]] \
|| fail "$E_USAGE" "$flag: '$path' is not a readable file (regular file, fifo or character device)"
Expand Down
26 changes: 20 additions & 6 deletions src/main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,23 @@ Agents:
# handle instead of numeric id.
5dive agent <name> tui # attach to an interactive agent tmux session (dispatcher-only seats explain the alternative)
5dive agent logs <name> [--follow] [--lines=N] [--tmux]
5dive agent send <name> <text...>|--message=<text>|--message-file=<path>
5dive agent send <name> --message-file=<path>|--message-file=-|--message=<text>|<text...>
[--from=<sender>] [--raw] [--wake]
[--reply-to-chat=<id> [--reply-to-msg=<id>]]
# --message-file reads the body VERBATIM from a file (DIVE-2627).
# Use it for ANY message that quotes CLI verbs: inside a double-quoted
# --message=, backtick-quoted verbs RUN as command substitution (as you),
# the words are deleted, and the send still prints OK.
# --message-file is FIRST because it is the only form your shell
# cannot corrupt: the body is read VERBATIM (DIVE-2627), and '-'
# reads it from stdin, so no temp file is needed:
# 5dive agent send dev --message-file=- <<'EOF'
# ...your text, quotes and all...
# EOF
# Write the heredoc with a QUOTED <<'EOF'; an unquoted <<EOF
# expands inside the heredoc and is not safe.
# --message=<text> / <text...>: SHORT PLAIN TEXT ONLY. Your shell
# expands \$VAR, \`cmd\` and \$(cmd) BEFORE 5dive sees them, so
# "US\$4,500" is delivered as "US,500" (\$4 is an empty positional)
# and a backtick-quoted CLI verb RUNS as you and is deleted from
# the text -- and the send still prints OK on both. Anything with
# code, currency, a path, a quote or a newline goes in a file.
# inject a message (tmux send-keys + Enter).
# When called from another agent, auto-wraps as
# [5dive-msg from=<caller> id=<id>] so the
Expand Down Expand Up @@ -202,11 +212,15 @@ Agents:
# keystroke that may have been dropped. --json then
# reports ready=proven, or ready=unprovable for a
# runtime whose prompt cannot be detected at all.
5dive agent ask <name> <text...> [--from=<sender>] [--timeout=120] [--idle-secs=5] [--poll-secs=2]
5dive agent ask <name> --message-file=<path>|--message-file=-|--message=<text>|<text...>
[--from=<sender>] [--timeout=120] [--idle-secs=5] [--poll-secs=2]
[--reply-to-chat=<id> [--reply-to-msg=<id>]]
# synchronous send + wait. Polls scrollback after
# the marker line until it stops growing for
# --idle-secs, then prints the reply body.
# Same body flags, same hazard and same order as
# 'agent send' above: --message-file (or '-' for
# stdin) is the form your shell cannot corrupt.
5dive agent stats <name> # state, restart count, last exit
5dive agent install <type> [--upgrade] # install the CLI for a type if missing (--upgrade forces a reinstall)
5dive agent set-account <agent> <account|default> # rebind to a named account; "default" clears
Expand Down
Loading
Loading