diff --git a/changelog.d/DIVE-4421.md b/changelog.d/DIVE-4421.md new file mode 100644 index 00000000..9e4260cd --- /dev/null +++ b/changelog.d/DIVE-4421.md @@ -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=` has read the body +verbatim since DIVE-2627; its warning lived in a parser comment, and the usage line read +`send |--message=|--message-file=` — 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=|--message-file=-|--message=|`, 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. diff --git a/src/cmd_agent_runtime.sh b/src/cmd_agent_runtime.sh index 3f1b1aee..40115ce3 100644 --- a/src/cmd_agent_runtime.sh +++ b/src/cmd_agent_runtime.sh @@ -2446,6 +2446,31 @@ _agent_send_row_hint() { return 0 } +# _agent_body_shell_hint — 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=, 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="" @@ -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") ;; @@ -2477,7 +2509,7 @@ cmd_send() { name="${positional[0]}" positional=("${positional[@]:1}") fi - [[ -n "$name" ]] || fail "$E_USAGE" "usage: 5dive agent send | --message= | --message-file= [--from=] [--raw] [--wake] [--reply-to-chat= [--reply-to-msg=]]" + [[ -n "$name" ]] || fail "$E_USAGE" "usage: 5dive agent send --message-file=|--message-file=- (VERBATIM — your shell cannot corrupt it) | --message= | [--from=] [--raw] [--wake] [--reply-to-chat= [--reply-to-msg=]]" # 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 @@ -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 @@ -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 @@ -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=}" ;; @@ -2799,7 +2844,7 @@ cmd_ask() { name="${positional[0]}" positional=("${positional[@]:1}") fi - [[ -n "$name" ]] || fail "$E_USAGE" "usage: 5dive agent ask [--from=] [--reply-to-chat= [--reply-to-msg=]] [--timeout=120] [--idle-secs=5] [--poll-secs=2] [--deliver-secs=30] [--allow-unfenced]" + [[ -n "$name" ]] || fail "$E_USAGE" "usage: 5dive agent ask --message-file=|--message-file=- (VERBATIM — your shell cannot corrupt it) | --message= | [--from=] [--reply-to-chat= [--reply-to-msg=]] [--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 @@ -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 diff --git a/src/lib/validation.sh b/src/lib/validation.sh index cb7ea573..5de97954 100644 --- a/src/lib/validation.sh +++ b/src/lib/validation.sh @@ -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}=" + 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)" diff --git a/src/main.sh b/src/main.sh index d7858a31..79acf295 100644 --- a/src/main.sh +++ b/src/main.sh @@ -167,13 +167,23 @@ Agents: # handle instead of numeric id. 5dive agent tui # attach to an interactive agent tmux session (dispatcher-only seats explain the alternative) 5dive agent logs [--follow] [--lines=N] [--tmux] - 5dive agent send |--message=|--message-file= + 5dive agent send --message-file=|--message-file=-|--message=| [--from=] [--raw] [--wake] [--reply-to-chat= [--reply-to-msg=]] - # --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 < / : 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= id=] so the @@ -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 [--from=] [--timeout=120] [--idle-secs=5] [--poll-secs=2] + 5dive agent ask --message-file=|--message-file=-|--message=| + [--from=] [--timeout=120] [--idle-secs=5] [--poll-secs=2] [--reply-to-chat= [--reply-to-msg=]] # 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 # state, restart count, last exit 5dive agent install [--upgrade] # install the CLI for a type if missing (--upgrade forces a reinstall) 5dive agent set-account # rebind to a named account; "default" clears diff --git a/tests/agent_send_verbatim_unit.sh b/tests/agent_send_verbatim_unit.sh new file mode 100755 index 00000000..0bca60ae --- /dev/null +++ b/tests/agent_send_verbatim_unit.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +# TIER: nightly — ~9s measured on the 5dive control-plane VM (standalone runs: 8.4s, +# 9.1s; the slower is claimed on purpose, a low claim is what the DIVE-2555 grader +# reds). Most of that is ONE `build.sh` into a throwaway path, which arm A needs and +# cannot fake: the acceptance clause says grade the RENDERED --help, not the source, +# and the source is not the artifact a caller reads. Nightly rather than core for the +# same reason prose_file_flags_unit.sh gives — core is already over its 300s ratchet +# on this box — and the bill is the same one: a future PR that breaks this file reds +# it after merging. changed-harnesses covers the PR that touches this file. +# +# DIVE-4421 unit harness: `agent send` / `agent ask` teach the VERBATIM body form +# first, accept it from stdin, and nudge a caller who is hand-assembling a body. +# +# WHY THE RENDERED HELP IS GRADED AND NOT src/main.sh. The usage block lives in an +# UNQUOTED heredoc (`cat <&2 +cd "$(dirname "$0")/.." +SRC=src +TMP="$(mktemp -d /tmp/agent-send-verbatim.XXXXXX)" +trap 'rc=$?; rm -rf "${TMP:-}"; echo "HARNESS-RC=$rc"' EXIT # DIVE-2692: one trap, every exit path + +PASS=0; FAIL=0 +ok_t() { PASS=$((PASS+1)); printf 'ok - %s\n' "$1"; } +bad_t() { FAIL=$((FAIL+1)); printf 'FAIL - %s\n %s\n' "$1" "${2:-}"; } + +# --------------------------------------------------------------------------- +# THE PAYLOAD — every hazard the customer report names, at once. +# --------------------------------------------------------------------------- +# Quoted-delimiter heredoc, the one form safe in both directions, so this file's +# own authoring does not mangle the thing it grades. `US$4,500` and the backticked +# verb are the two VERBATIM instances from the 2026-09-13 report; $HOME and $(date) +# are the classes those two instances belong to. All four are needed: a payload +# carrying three of them passes against a half-fix. +PAYLOAD=$(cat <<'PAYLOAD_EOF' +budget is US$4,500 and $HOME must not expand, nor $(date) +run `5dive task need DIVE-1 --ask="x"` first, then `5dive push` +don't drop the maker's words — an apostrophe ends a single-quoted string +PAYLOAD_EOF +) +PAYLOAD="$PAYLOAD"$'\n' # trailing newline, on purpose: it is what $(cat f) eats + +anchor_ok=1 +[[ "$PAYLOAD" == *'US$4,500'* ]] || { anchor_ok=0; bad_t "ANCHOR: payload carries the reported currency case" ""; } +[[ "$PAYLOAD" == *'`'* ]] || { anchor_ok=0; bad_t "ANCHOR: payload carries a backtick" ""; } +[[ "$PAYLOAD" == *'$(date)'* ]] || { anchor_ok=0; bad_t "ANCHOR: payload carries a command substitution" ""; } +[[ "$PAYLOAD" == *"'"* ]] || { anchor_ok=0; bad_t "ANCHOR: payload carries an apostrophe" ""; } +[[ "$PAYLOAD" == *$'\n' ]] || { anchor_ok=0; bad_t "ANCHOR: payload ends with a newline" ""; } +(( anchor_ok )) && ok_t "ANCHOR: payload carries every reported hazard class + a trailing newline" + +# --------------------------------------------------------------------------- +# ARM A — the RENDERED help (acceptance (a)) +# --------------------------------------------------------------------------- +HELP_BIN="$TMP/5dive-help" +if BUILD_OUT="$HELP_BIN" ./build.sh >"$TMP/build.log" 2>&1; then + "$HELP_BIN" --help >"$TMP/help.txt" 2>&1 + send_line=$(grep -n '^ 5dive agent send ' "$TMP/help.txt" | head -1 | cut -d: -f1) + if [[ -n "$send_line" ]]; then + sed -n "${send_line},\$p" "$TMP/help.txt" | sed -n '1,25p' > "$TMP/send_block.txt" + # BYTE offsets, not line numbers: both forms sit on the SAME synopsis line, so + # a line-number comparison reads 1 < 1 and grades nothing about the order. + mf=$(grep -b -o -m1 -- '--message-file' "$TMP/send_block.txt" | head -1 | cut -d: -f1) + mi=$(grep -b -o -m1 -- '--message=' "$TMP/send_block.txt" | head -1 | cut -d: -f1) + { [[ -n "$mf" && -n "$mi" ]] && (( mf < mi )); } \ + && ok_t "A1 rendered help: --message-file is named BEFORE --message on the send synopsis" \ + || bad_t "A1 --message-file first" "message-file at byte ${mf:-none}, message at byte ${mi:-none}: $(head -1 "$TMP/send_block.txt")" + # The expansion warning, graded on the RENDERED bytes. The literal "\$VAR" and + # the backticks below are exactly what the unquoted heredoc would have eaten. + { grep -q 'expands \$VAR' "$TMP/send_block.txt" \ + && grep -q '`cmd`' "$TMP/send_block.txt" \ + && grep -q '\$(cmd)' "$TMP/send_block.txt" \ + && grep -q 'US\$4,500' "$TMP/send_block.txt"; } \ + && ok_t "A2 rendered help carries the shell-expansion line, unexpanded (\$VAR, \`cmd\`, \$(cmd), US\$4,500)" \ + || bad_t "A2 expansion line survives rendering" "$(cat "$TMP/send_block.txt")" + grep -q "message-file=- <<'EOF'" "$TMP/send_block.txt" \ + && ok_t "A3 rendered help shows the stdin heredoc form with a QUOTED delimiter" \ + || bad_t "A3 stdin form in help" "$(cat "$TMP/send_block.txt")" + else + bad_t "A1-A3 rendered help" "no '5dive agent send' line in --help" + fi + # `agent send --help` itself: it used to die with `unknown flag: --help`. + "$HELP_BIN" agent send --help >"$TMP/send_help.txt" 2>"$TMP/send_help.err"; rc=$? + { [[ $rc -eq 0 ]] && grep -q -- '--message-file' "$TMP/send_help.txt"; } \ + && ok_t "A4 '5dive agent send --help' exits 0 and renders the body flags (was: unknown flag)" \ + || bad_t "A4 send --help" "rc=$rc err: $(cat "$TMP/send_help.err")" + "$HELP_BIN" agent ask --help >"$TMP/ask_help.txt" 2>&1; rc=$? + { [[ $rc -eq 0 ]] && grep -q -- '--message-file' "$TMP/ask_help.txt"; } \ + && ok_t "A5 '5dive agent ask --help' exits 0 and renders the body flags" \ + || bad_t "A5 ask --help" "rc=$rc out: $(head -3 "$TMP/ask_help.txt")" +else + bad_t "A1-A5 rendered help" "NOT-REACHED: build.sh failed — $(tail -3 "$TMP/build.log")" +fi + +# --------------------------------------------------------------------------- +# In-process arms: source src/ directly (same isolation contract as +# prose_file_flags_unit.sh — nothing here touches the live registry or tmux). +# --------------------------------------------------------------------------- +# shellcheck disable=SC1090 +for f in header.sh lib/error_codes.sh lib/output.sh lib/validation.sh \ + lib/agent_setup.sh lib/state.sh lib/audit.sh lib/registry.sh \ + lib/tasks_db.sh lib/actor.sh cmd_agent_runtime.sh; do + source "$SRC/$f" +done +STATE_DIR="$TMP" +set +e # header.sh enabled `set -e`; these arms expect non-zero exits + +hexof() { od -An -tx1 -v | tr -d ' \n' | tr 'a-f' 'A-F'; } +PAYLOAD_HEX=$(printf '%s' "$PAYLOAD" | hexof) + +# THE CAPTURE. cmd_send's last act before it needs a live agent is the round +# guard, which receives the fully-resolved body as $3. Stubbing it is how this +# harness reads the bytes cmd_send would deliver without a tmux session: the real +# guard is called inside `$( )`, so the stub writes to a FILE — a variable +# assignment in that subshell would vanish (DIVE-4342's lesson, same shape). +# a2a_needs_scoped is forced false so cmd_send does not exec into sudo. +a2a_needs_scoped() { return 1; } +a2a_round_guard() { printf '%s' "$3" > "$TMP/captured"; return 0; } +require_agent() { echo "require_agent stub: stop here" >&2; return 1; } +# usage() lives in main.sh, which is not sourced here (it ends in `main "$@"`). +usage() { echo ""; } + +send_capture() { # send_capture ; prints nothing, sets CAP_RC + local stdin_src="$1"; shift + rm -f "$TMP/captured" + if [[ "$stdin_src" == "none" ]]; then + ( cmd_send "$@" ) >"$TMP/out" 2>"$TMP/err" + else + ( cmd_send "$@" ) <"$stdin_src" >"$TMP/out" 2>"$TMP/err" + fi + CAP_RC=$? +} + +# --------------------------------------------------------------------------- +# ARM B — --message-file=- is byte-identical (acceptance (b)) +# --------------------------------------------------------------------------- +printf '%s' "$PAYLOAD" > "$TMP/payload.txt" +send_capture "$TMP/payload.txt" dev --message-file=- +if [[ -f "$TMP/captured" ]]; then + [[ "$(hexof < "$TMP/captured")" == "$PAYLOAD_HEX" ]] \ + && ok_t "B1 --message-file=- delivers the body BYTE-identically (currency, backticks, \$HOME, \$(date), apostrophe, trailing newline)" \ + || bad_t "B1 stdin body byte-identical" "got: $(cat "$TMP/captured")" +else + bad_t "B1 stdin body byte-identical" "cmd_send never reached the round guard; err: $(cat "$TMP/err")" +fi + +# B2 — THE MUTATION ARM. Cut the stdin branch out of the SHIPPING function's own +# text (not a substituted stub) and assert the same assertion goes red. A reader +# that keeps `-` as a literal path is the obvious wrong implementation. +_real_reader=$(declare -f _read_prose_file) +eval "$(declare -f _read_prose_file | sed 's/if \[\[ "$path" == "-" \]\]; then/if false; then/')" +declare -f _read_prose_file | grep -q 'if false; then' \ + && ok_t "B2a MUTATION: the stdin branch is cut out of the shipping function's own text" \ + || bad_t "B2a mutation landed" "$(declare -f _read_prose_file | head -20)" +send_capture "$TMP/payload.txt" dev --message-file=- +{ [[ ! -f "$TMP/captured" ]] || [[ "$(hexof < "$TMP/captured")" != "$PAYLOAD_HEX" ]]; } \ + && ok_t "B2b MUTATION: without the stdin branch the SAME assertion fails (it is not vacuous)" \ + || bad_t "B2b mutation should have failed the round-trip" "the assertion grades nothing" +eval "$_real_reader" +declare -f _read_prose_file | grep -q 'if \[\[ "$path" == "-" \]\]' \ + && ok_t "B2c MUTATION: the real reader is restored before the remaining arms" \ + || bad_t "B2c reader not restored" "$(declare -f _read_prose_file | head -20)" + +# B3 — the same body through `agent ask`, which had no file form at all before this. +CAP_ASK="" +rm -f "$TMP/captured" +( cmd_ask dev --message-file=- ) <"$TMP/payload.txt" >"$TMP/out" 2>"$TMP/err" +# cmd_ask does not reach a2a_round_guard, so grade the reader it now calls. +_PROSE_FILE_VALUE="" +( _read_prose_file --message-file - <"$TMP/payload.txt"; printf '%s' "$_PROSE_FILE_VALUE" > "$TMP/ask_body" ) +{ [[ -f "$TMP/ask_body" ]] && [[ "$(hexof < "$TMP/ask_body")" == "$PAYLOAD_HEX" ]]; } \ + && ok_t "B3 'agent ask' accepts --message-file and reads it byte-identically (it had no file form at all)" \ + || bad_t "B3 ask --message-file" "err: $(cat "$TMP/err")" + +# --------------------------------------------------------------------------- +# ARM C — a real path keeps every refusal DIVE-2627 shipped (acceptance (c)) +# --------------------------------------------------------------------------- +# CORRECTION TO THE ROW, recorded here because the harness is where it is +# checkable: DIVE-4421's clause (c) asks that "a real path still refuses a +# symlink / a file another seat owns". NO SUCH CHECK EXISTS on main and none was +# added — _read_prose_file gates on -e / -f|-p|-c / -r only, and `-f` FOLLOWS a +# symlink, so a symlink to a readable regular file is accepted today and still is. +# Asserting a refusal that never existed would red this file for a reason unrelated +# to the diff; asserting the ones that DO exist is what "arms unchanged" can mean. +send_capture none dev --message-file="$TMP/nope.txt" +{ [[ $CAP_RC -eq $E_USAGE ]] && grep -q "no such file" "$TMP/err"; } \ + && ok_t "C1 a real path that does not exist is still refused (E_USAGE)" \ + || bad_t "C1 missing path refusal" "rc=$CAP_RC err: $(cat "$TMP/err")" +mkdir -p "$TMP/adir" +send_capture none dev --message-file="$TMP/adir" +{ [[ $CAP_RC -eq $E_USAGE ]] && grep -q "not a readable file" "$TMP/err"; } \ + && ok_t "C2 a non-regular path (directory) is still refused (E_USAGE)" \ + || bad_t "C2 directory refusal" "rc=$CAP_RC err: $(cat "$TMP/err")" +: > "$TMP/empty.txt" +send_capture none dev --message-file="$TMP/empty.txt" +{ [[ $CAP_RC -eq $E_VALIDATION ]] && grep -q "is empty" "$TMP/err"; } \ + && ok_t "C3 an EMPTY file is still refused (E_VALIDATION), not sent as an empty body" \ + || bad_t "C3 empty file refusal" "rc=$CAP_RC err: $(cat "$TMP/err")" +send_capture "$TMP/empty.txt" dev --message-file=- +{ [[ $CAP_RC -eq $E_VALIDATION ]] && grep -q "stdin was empty" "$TMP/err"; } \ + && ok_t "C4 EMPTY stdin is refused the same way a file is — '-' is not a hole in the guard" \ + || bad_t "C4 empty stdin refusal" "rc=$CAP_RC err: $(cat "$TMP/err")" +send_capture "$TMP/payload.txt" dev --message-file=- --message=x +{ [[ $CAP_RC -eq $E_USAGE ]] && grep -q "conflicts with" "$TMP/err"; } \ + && ok_t "C5 --message-file + --message is still refused rather than silently picking one" \ + || bad_t "C5 dupe refusal" "rc=$CAP_RC err: $(cat "$TMP/err")" +send_capture "$TMP/payload.txt" dev --message-file=- trailing words +{ [[ $CAP_RC -eq $E_USAGE ]] && grep -q "conflicts with the positional" "$TMP/err"; } \ + && ok_t "C6 --message-file + positional text is still refused rather than dropping the words" \ + || bad_t "C6 positional conflict" "rc=$CAP_RC err: $(cat "$TMP/err")" +rm -f "$TMP/captured" +( cmd_ask dev --message-file="$TMP/payload.txt" extra words ) >"$TMP/out" 2>"$TMP/err"; rc=$? +{ [[ $rc -eq $E_USAGE ]] && grep -q "conflicts with the positional" "$TMP/err"; } \ + && ok_t "C7 the same positional guard covers 'agent ask' (the new flag there gets the guard too)" \ + || bad_t "C7 ask positional conflict" "rc=$rc err: $(cat "$TMP/err")" + +# --------------------------------------------------------------------------- +# ARM D — the send-side hint (acceptance (d)) +# --------------------------------------------------------------------------- +hint_fires() { _agent_body_shell_hint "$1" "${2:-}" 2>&1; } +[[ -n "$(hint_fires 'a $x' '--message')" ]] \ + && ok_t "D1 the hint FIRES on --message='a \$x'" \ + || bad_t "D1 hint on \$" "printed nothing" +[[ -n "$(hint_fires 'see `5dive push`' '--message')" ]] \ + && ok_t "D2 the hint FIRES on a --message body carrying a backtick" \ + || bad_t "D2 hint on backtick" "printed nothing" +[[ -n "$(hint_fires 'a $x' '')" ]] \ + && ok_t "D3 the hint FIRES on the POSITIONAL form too (it went through the same shell)" \ + || bad_t "D3 hint on positional" "printed nothing" +[[ -z "$(hint_fires 'a $x' '--message-file')" ]] \ + && ok_t "D4 the hint is SILENT on --message-file (the form it exists to recommend)" \ + || bad_t "D4 hint silent on --message-file" "$(hint_fires 'a $x' '--message-file')" +[[ -z "$(hint_fires 'plain prose, no metacharacters' '--message')" ]] \ + && ok_t "D5 the hint is SILENT on an ordinary --message body (no nag on every send)" \ + || bad_t "D5 hint silent on plain prose" "$(hint_fires 'plain prose, no metacharacters' '--message')" +out=$(hint_fires 'a $x' '--message'); rc=$? +{ [[ $rc -eq 0 ]] && grep -q -- '--message-file' <<<"$out"; } \ + && ok_t "D6 the hint returns 0 (it can never fail a send) and NAMES --message-file" \ + || bad_t "D6 hint rc/content" "rc=$rc out=$out" +# The property that makes D1-D6 a reminder and not a detector, asserted rather +# than only commented: the reported CORRUPTED body has no metacharacter left in +# it by the time argv exists, so the hint is silent on it. This arm documents the +# declined request 2 in code — if someone later "fixes" the hint into a detector, +# this is the arm that tells them what they changed. +[[ -z "$(hint_fires 'the fee is US,500' '--message')" ]] \ + && ok_t "D7 the hint is SILENT on the already-corrupted body ('US,500') — a reminder, NOT a detector" \ + || bad_t "D7 hint must not claim to detect corruption" "$(hint_fires 'the fee is US,500' '--message')" + +printf '\n%s\n' "PASS=$PASS FAIL=$FAIL" +(( FAIL == 0 ))