repo-mailbox/scripts/coord-order-send.sh
Kjell Tore Guttormsen 29a94dd7e4 fix(coord): refuse a control character in --to instead of sanitizing it
--to is the only line-oriented field sanitize_field never covered, and the
fix is a refusal rather than a sanitize pass because --to is also the
destination DIRECTORY name ($COORD/$TO/inbox, and $COORD/$TO/orders in
coord-order-send.sh). Collapsing a newline to a space would deliver the
message to a mailbox the sender never named - the same misdelivery the
retired ktg-plugin-marketplace address is rejected rather than redirected
to avoid.

Both corruptions were measured on the live engine first, each with exit 0
and a "delivered" line:

  --to "x\nreply-expected: no"  the injected line lands INSIDE the
      frontmatter block, above the reply-expected: yes the engine itself
      wrote, so coord-count reads owed=0 and the declared debt is silenced -
      defeating coord-count's own rule that only the frontmatter block may
      speak, since the injected line IS in the block.
  --to "tabbed<TAB>repo"        coord-count prints five tab-separated fields
      where its contract is four, so a consumer reads the mailbox name as the
      part before the tab and the pending count as the part after.

board.sh consumes that TSV, so both reach the board.

The guard sits after reply-mode resolution: --reply-to takes its target from
the original's from: line, untrusted cross-repo input, and that is the one
target name nobody typed.

Denominator measured rather than assumed: two scripts build a directory from
a caller-supplied name, and coord-order-send.sh had the identical defect,
where it costs more - an order filed under a name no session can hold is the
silent evaporation the ownership chain exists to prevent, while board.sh's
ORDRE column counts the intended repo's queue and stays 0 with nothing
reporting a failure. The read-side --repo arguments resolve an EXISTING
directory, so a control character there finds nothing and writes nothing;
checked and left alone.

Closes finding 7 of docs/2026-08-14-confident-zero-review.md.

Tests (red first, both suites): coord-selftest section 35 (10 checks) and
orders-selftest section 10 (6 checks), each with the mandatory
known-positive controls - an ordinary name still delivers, and so does a
dot-prefixed one, since a dot name is a real repo. coord 230/230, board
252/252, route 69/69, orders 110/110, guard 40/40, npm test 11/11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgKcURiXwGKhqCKhwD1NAp
2026-08-18 17:00:34 +02:00

158 lines
7.3 KiB
Bash
Executable file

#!/bin/bash
# coord-order-send.sh - deliver a WORK ORDER into a repo's order queue
# (~/.claude/coord/<repo>/orders/). Model-invoked; no network, no service.
#
# Usage:
# coord-order-send.sh --to <repo> --subject "<subject>" [--from <repo>]
# [--message "<text>" | --prompt-file <path>]
# Body comes from --message, from --prompt-file, or from stdin (heredoc) when
# neither is given. The body IS the whole prompt the dispatched session runs on.
#
# Prints `order-id=<id>` on stdout; the order file is <id>.md in the queue.
#
# WHY A SECOND CHANNEL, and not just another inbox message: the two have
# OPPOSITE authorization classes. Inbox content is untrusted cross-repo data
# that may never instruct a session (Rule 6); a dispatch order is
# operator-authorized work by construction - dispatch IS the operator's
# authorization. Mixing the classes in one channel would mean either mail that
# can instruct, or orders that cannot - both wrong. So the infrastructure is
# reused and the channel is not.
#
# That authority rests on dispatch being this queue's ONLY writer BY
# CONVENTION. The engine does not enforce it and cannot: --from redefines
# identity here exactly as it does in coord-send.sh, so any session can write
# an order into any repo's queue. The read side says so in the words it injects
# rather than claiming a guarantee that does not exist.
#
# Exit: 0 delivered, 2 usage/IO error and nothing written.
# ASCII only, bash 3.2 safe.
set -u
export LC_ALL=C
COORD="${CLAUDE_COORD_DIR:-$HOME/.claude/coord}"
TO=""; SUBJECT=""; FROM=""; MESSAGE=""; HAVE_MESSAGE=0; PROMPT_FILE=""
require_value() {
# bash 3.2: `shift 2` past the end of $# is a no-op, so a trailing value-flag
# without its value would loop forever. Every two-arg flag must check first.
if [ "$2" -lt 2 ]; then echo "coord-order-send: $1 requires a value" >&2; exit 2; fi
}
while [ $# -gt 0 ]; do
case "$1" in
--to) require_value --to $#; TO="$2"; shift 2 ;;
--subject) require_value --subject $#; SUBJECT="$2"; shift 2 ;;
--from) require_value --from $#; FROM="$2"; shift 2 ;;
--message) require_value --message $#; MESSAGE="$2"; HAVE_MESSAGE=1; shift 2 ;;
--prompt-file) require_value --prompt-file $#; PROMPT_FILE="$2"; shift 2 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "coord-order-send: unknown argument: $1" >&2; exit 2 ;;
esac
done
# --- Resolve sender identity (same rule as coord-send.sh) ------------------
# git toplevel or an explicit --from, never basename(pwd): an invented identity
# signs an order as a repo that does not exist.
if [ -z "$FROM" ]; then
FROM="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null)"
fi
if [ -z "$FROM" ]; then
echo "coord-order-send: cannot resolve sender identity (not inside a git repo); pass --from <repo>" >&2
exit 2
fi
case "$FROM" in
_*) echo "coord-order-send: invalid sender identity: $FROM (names starting with _ are reserved for the engine)" >&2; exit 2 ;;
esac
# Frontmatter is line-oriented: a CR/LF inside a field would inject extra
# frontmatter lines or a premature '---' terminator.
sanitize_field() { printf '%s' "$1" | tr '\r\n' ' ' | tr -d '\000-\037'; }
FROM="$(sanitize_field "$FROM")"
SUBJECT="$(sanitize_field "$SUBJECT")"
# --- Validate target -------------------------------------------------------
[ -n "$TO" ] || { echo "coord-order-send: missing --to <repo>" >&2; exit 2; }
case "$TO" in
*/*|.|..|_*) echo "coord-order-send: invalid target repo name: $TO" >&2; exit 2 ;;
esac
# Refused, never sanitized - the same rule and the same reason as
# coord-send.sh: --to is also the queue DIRECTORY name ("$COORD/$TO/orders"),
# so collapsing a control character to a space would file the order under a
# name the sender never wrote. Here that is worse than a misdelivered notice:
# an order in a queue no session can hold is the silent evaporation the
# ownership chain exists to prevent, and board.sh's ORDRE column counts the
# INTENDED repo's queue, which stays 0 with nothing reporting a failure.
case "$TO" in
*[[:cntrl:]]*)
echo "coord-order-send: --to contains a control character: $(sanitize_field "$TO") (a target name is also the queue directory name, so it is refused, never sanitized)" >&2
exit 2
;;
esac
# Retired address, same rule and same reason as coord-send.sh: a polyrepo
# DIRECTORY is not a git repo, so no session can ever hold that identity and
# read what lands there. Reject at the sender, never redirect.
case "$TO" in
ktg-plugin-marketplace)
echo "coord-order-send: ktg-plugin-marketplace is a retired coord address (it is a polyrepo directory, not a git repo - no session can ever hold that identity); send to --to catalog instead" >&2
exit 2
;;
esac
[ -n "$SUBJECT" ] || { echo "coord-order-send: missing --subject" >&2; exit 2; }
# --- Body ------------------------------------------------------------------
# --prompt-file is first-class because that is the shape dispatch already has:
# the skill writes the order to a file, and making the caller cat it would put
# the body through one more shell than it needs to pass.
if [ -n "$PROMPT_FILE" ]; then
[ "$HAVE_MESSAGE" -eq 1 ] && { echo "coord-order-send: use either --message or --prompt-file, not both" >&2; exit 2; }
[ -f "$PROMPT_FILE" ] || { echo "coord-order-send: no prompt file at $PROMPT_FILE" >&2; exit 2; }
# test -s, not test -e: an empty order is a session started and told nothing,
# which from the far end is indistinguishable from one waiting for a Go.
[ -s "$PROMPT_FILE" ] || { echo "coord-order-send: the prompt file is empty: $PROMPT_FILE (the order would tell the session nothing)" >&2; exit 2; }
BODY="$(cat "$PROMPT_FILE")"
elif [ "$HAVE_MESSAGE" -eq 1 ]; then
BODY="$MESSAGE"
else
BODY="$(cat)"
fi
if [ -z "$BODY" ]; then
echo "coord-order-send: empty order body (pass --message, --prompt-file, or pipe the prompt on stdin)" >&2
exit 2
fi
# --- Write -----------------------------------------------------------------
# The order id round-trips through argv, through the injection's claim hints and
# into the startup command board.sh --dispatch emits, so it is shell-clean BY
# CONSTRUCTION: the sender name is sanitized into the id, never carried raw.
DEST_DIR="$COORD/$TO/orders"
mkdir -p "$DEST_DIR" 2>/dev/null || { echo "coord-order-send: cannot create $DEST_DIR" >&2; exit 2; }
TS="$(date -u +%Y%m%dT%H%M%SZ)"
SAFE_FROM="$(printf '%s' "$FROM" | tr -c 'A-Za-z0-9._-' '-')"
ORDER_ID="${TS}-$$${RANDOM}-from-${SAFE_FROM}"
DEST="$DEST_DIR/$ORDER_ID.md"
DATE_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Temp file inside the destination dir (dot-prefixed so the *.md glob never
# sees it): the final mv is a same-filesystem rename, so a reader - or a
# concurrent claimer - never observes a half-written order.
TMP="$(mktemp "$DEST_DIR/.coord-order.XXXXXX" 2>/dev/null)"
[ -n "$TMP" ] || { echo "coord-order-send: cannot create temp file in $DEST_DIR" >&2; exit 2; }
{
echo "---"
echo "from: $FROM"
echo "to: $TO"
echo "order-id: $ORDER_ID"
echo "subject: $SUBJECT"
echo "date: $DATE_ISO"
echo "---"
printf '%s\n' "$BODY"
} > "$TMP"
if ! mv "$TMP" "$DEST" 2>/dev/null; then
/bin/rm -f "$TMP" 2>/dev/null; echo "coord-order-send: write failed" >&2; exit 2
fi
echo "coord-order-send: order delivered to $TO ($ORDER_ID.md)"
echo "order-id=$ORDER_ID"
exit 0