repo-mailbox/scripts/coord-send.sh
Kjell Tore Guttormsen 1d62b073f8 fix(engine)!: identity is derived, never invented
Four defects that all reduce to the same thing: the engine trusted a name it
had no business trusting.

_broadcast is now a reserved namespace, not a repo. coord-send guarded
retraction with a sender check, but that guard only covered the door it was
nailed to: `coord-done --repo _broadcast <file>` walked in the side entrance
and archived a broadcast out of the queue - a full unauthenticated retract of
an announcement for every repo that had not read it yet. The rule reserves the
whole `_` prefix rather than one literal, so a later `_seen` or `_config`
cannot reopen the hole, and it is enforced in every CLI: a rule held in three
of four places is not a rule.

The pwd fallback is gone. It existed so the CLI would work anywhere, but
"anywhere" includes every global surface: a session in ~/repos is not a repo,
and basename(pwd) silently handed it the identity "repos". That is not
hypothetical - mail was delivered under exactly that name. git toplevel or an
explicit --from/--repo are now the only two sources. The write paths refuse
and say so; the read path declines silently, because the hook runs it at every
session start and must never fail a session.

Two checkouts with the same directory name still share one mailbox - re-keying
identity would break every existing mailbox and the readable `--to <repo>`
addressing. Instead the first git-derived read records the claiming path in
<repo>/.origin, and a read from elsewhere is warned about in the injection. A
warning, not a refusal: the same repo moved or re-cloned is the ordinary case.
The warning goes in the injection because the hook discards stderr, and a
warning nobody can see is not a warning. The collision is live in this tree:
claude-code-100x is nested inside a repo of the same name.

Broadcast delivery is recorded only after the injection is written. Marking
inside the read loop left a window where the seen set said "delivered" while
the operator saw nothing, and the hook runs under `timeout: 10`, so the window
was reachable. A lost broadcast is unrecoverable by design - the seen set is
delivery history and retraction deliberately leaves it alone - so the failure
mode has to be redelivery, never loss.

The hook stops resolving identity altogether. It was the fourth copy of the
rule and the only one that runs in production, so passing --repo bypassed the
engine's guards exactly where they mattered and suppressed the collision check
along with them. It is now the pure wrapper the boundary rule always claimed
it was, pinned by two behavioral tests rather than by reading the source.

Selftest 93 -> 116; three node tests cover the hook.

BREAKING CHANGE: coord-send and coord-done exit 2 outside a git repo instead
of naming themselves after the working directory. Pass --from/--repo to choose
an identity explicitly. Repo names beginning with _ are refused everywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
2026-07-25 20:34:43 +02:00

215 lines
10 KiB
Bash
Executable file

#!/bin/bash
# coord-send.sh - deliver an inter-repo coordination message into the local
# mailbox (~/.claude/coord). Model-invoked; no network, no external service.
#
# Usage:
# coord-send.sh --to <repo> --subject "<subject>" [--from <repo>] [--message "<text>"]
# coord-send.sh --broadcast --subject "<subject>" [--from <repo>] [--message "<text>"]
# coord-send.sh --reply-to <file> [--subject "Re: ..."] [--from <repo>] [--message "<text>"]
# coord-send.sh --retract <file> [--from <repo>]
# Body comes from --message, or from stdin (heredoc) when --message is omitted.
# --reply-to <basename> replies to a message in THIS repo's inbox/archive: it
# routes to the original sender and marks the original handled (coord-done).
# --retract <basename> retires one of YOUR OWN broadcasts: it is archived out of
# the broadcast queue so no future repo receives it. This is un-send, not
# recall - repos that already received it are unaffected.
# --from overrides the sender/self identity (default: basename of git toplevel/cwd).
#
# Exit: 0 delivered, 2 usage/IO error. ASCII only, bash 3.2 safe.
set -u
export LC_ALL=C
COORD="${CLAUDE_COORD_DIR:-$HOME/.claude/coord}"
TO=""; BROADCAST=0; SUBJECT=""; FROM=""; MESSAGE=""; HAVE_MESSAGE=0; REPLYTO=""; REPLY_ORIG=""
RETRACT=""
# 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.
require_value() {
if [ "$2" -lt 2 ]; then echo "coord-send: $1 requires a value" >&2; exit 2; fi
}
while [ $# -gt 0 ]; do
case "$1" in
--to) require_value --to $#; TO="$2"; shift 2 ;;
--broadcast) BROADCAST=1; shift ;;
--reply-to) require_value --reply-to $#; REPLYTO="$2"; shift 2 ;;
--retract) require_value --retract $#; RETRACT="$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 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "coord-send: unknown argument: $1" >&2; exit 2 ;;
esac
done
# --- Resolve sender / self identity ---
# git toplevel or an explicit --from, and nothing else. basename(pwd) used to
# be the last resort, but every global surface (~/repos, $HOME) is a directory
# without a repo, and the fallback quietly handed one an identity like "repos" -
# a real message was delivered under exactly that name. An invented identity is
# worse than none: it signs mail as a repo that does not exist and, on the read
# side, opens a mailbox that may belong to someone else. Refuse and say how.
if [ -z "$FROM" ]; then
FROM="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null)"
fi
if [ -z "$FROM" ]; then
echo "coord-send: cannot resolve sender identity (not inside a git repo); pass --from <repo> to choose one explicitly" >&2
exit 2
fi
# A leading _ is reserved for engine internals (_broadcast today; the rule
# reserves the namespace so a later _seen or _config cannot reopen the hole).
case "$FROM" in
_*) echo "coord-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. Collapse newlines to
# spaces and drop remaining control characters. Send-side input hygiene;
# the read side independently treats all content as untrusted.
sanitize_field() { printf '%s' "$1" | tr '\r\n' ' ' | tr -d '\000-\037'; }
FROM="$(sanitize_field "$FROM")"
# --- Retract mode: retire one of our own broadcasts ---
# A broadcast has no per-repo owner that can close it: coord-done is directed-only
# and never touches _broadcast/. Without this branch a stale announcement stays in
# the queue and is delivered to every FUTURE repo forever, so the backlog can only
# grow. Runs before every send-side validation and before the stdin body read: a
# retract carries no subject and no body.
# The sender check is an accident guard, not a security boundary - --from
# redefines identity here exactly as it does everywhere else in this script. It
# exists so a wrong filename cannot silently retire another repo's announcement.
if [ -n "$RETRACT" ]; then
if [ "$BROADCAST" -eq 1 ] || [ -n "$TO" ] || [ -n "$REPLYTO" ]; then
echo "coord-send: --retract cannot be combined with --to/--broadcast/--reply-to" >&2; exit 2
fi
case "$RETRACT" in */*|.|..|"") echo "coord-send: invalid --retract name: $RETRACT" >&2; exit 2 ;; esac
BC_INBOX="$COORD/_broadcast/inbox"
BC_ARCHIVE="$COORD/_broadcast/archive"
if [ ! -e "$BC_INBOX/$RETRACT" ]; then
# Idempotent like coord-done: retracting twice is a no-op, not an error.
if [ -e "$BC_ARCHIVE/$RETRACT" ]; then
echo "coord-send: broadcast already retracted ($RETRACT)"; exit 0
fi
echo "coord-send: --retract broadcast not found: $RETRACT" >&2; exit 2
fi
ORIG_FROM="$(grep -m1 '^from:' "$BC_INBOX/$RETRACT" 2>/dev/null | sed 's/^from:[[:space:]]*//')"
if [ "$ORIG_FROM" != "$FROM" ]; then
echo "coord-send: refusing to retract a broadcast sent by ${ORIG_FROM:-unknown} (you are $FROM); pass --from ${ORIG_FROM:-<sender>} if that is really you" >&2
exit 2
fi
# Archived, never deleted (coord-done's rule), and the move stays inside
# _broadcast/ so it is a same-filesystem rename: no half-retracted state.
mkdir -p "$BC_ARCHIVE" 2>/dev/null || { echo "coord-send: cannot create $BC_ARCHIVE" >&2; exit 2; }
if ! mv "$BC_INBOX/$RETRACT" "$BC_ARCHIVE/$RETRACT" 2>/dev/null; then
echo "coord-send: retract failed for $RETRACT" >&2; exit 2
fi
# _broadcast/seen/* is deliberately left alone: those entries are delivery
# history, and one naming an archived file is inert.
echo "coord-send: retracted broadcast ($RETRACT); repos that already received it are unaffected"
exit 0
fi
# --- Reply mode: resolve target + default subject from the original message ---
if [ -n "$REPLYTO" ]; then
if [ "$BROADCAST" -eq 1 ] || [ -n "$TO" ]; then
echo "coord-send: --reply-to cannot be combined with --to/--broadcast" >&2; exit 2
fi
case "$REPLYTO" in */*|.|..|"") echo "coord-send: invalid --reply-to name: $REPLYTO" >&2; exit 2 ;; esac
REPLY_ORIG="$COORD/$FROM/inbox/$REPLYTO"
[ -e "$REPLY_ORIG" ] || REPLY_ORIG="$COORD/$FROM/archive/$REPLYTO"
if [ ! -e "$REPLY_ORIG" ]; then
echo "coord-send: --reply-to message not found for $FROM: $REPLYTO" >&2; exit 2
fi
TO="$(grep -m1 '^from:' "$REPLY_ORIG" 2>/dev/null | sed 's/^from:[[:space:]]*//')"
[ -z "$TO" ] && { echo "coord-send: cannot resolve sender of $REPLYTO" >&2; exit 2; }
if [ -z "$SUBJECT" ]; then
osub="$(grep -m1 '^subject:' "$REPLY_ORIG" 2>/dev/null | sed 's/^subject:[[:space:]]*//')"
SUBJECT="Re: $osub"
fi
fi
SUBJECT="$(sanitize_field "$SUBJECT")"
# --- Validate target: exactly one of --to / --broadcast (reply mode sets --to above) ---
if [ "$BROADCAST" -eq 1 ] && [ -n "$TO" ]; then
echo "coord-send: use either --to <repo> or --broadcast, not both" >&2; exit 2
fi
if [ "$BROADCAST" -eq 0 ] && [ -z "$TO" ]; then
echo "coord-send: missing --to <repo> / --broadcast / --reply-to" >&2; exit 2
fi
if [ "$BROADCAST" -eq 0 ]; then
# _* rather than the single literal _broadcast: the reserved namespace is a
# rule, so a future internal directory is covered the day it is added.
case "$TO" in
*/*|.*|_*) echo "coord-send: invalid target repo name: $TO" >&2; exit 2 ;;
esac
fi
if [ -z "$SUBJECT" ]; then
echo "coord-send: missing --subject" >&2; exit 2
fi
# --- Body: --message or stdin ---
if [ "$HAVE_MESSAGE" -eq 1 ]; then BODY="$MESSAGE"; else BODY="$(cat)"; fi
if [ -z "$BODY" ]; then
echo "coord-send: empty message body (pass --message or pipe text on stdin)" >&2; exit 2
fi
# --- Resolve destination ---
if [ "$BROADCAST" -eq 1 ]; then
TARGET_LABEL="broadcast"; DEST_DIR="$COORD/_broadcast/inbox"
else
TARGET_LABEL="$TO"; DEST_DIR="$COORD/$TO/inbox"
fi
mkdir -p "$DEST_DIR" 2>/dev/null || { echo "coord-send: cannot create $DEST_DIR" >&2; exit 2; }
# The filename must be shell-clean: it round-trips through coord-inbox's
# reply/resolve hints and coord-done's positional args. Sanitize the sender
# for the filename only; the raw name stays in the from: frontmatter.
TS="$(date -u +%Y%m%dT%H%M%SZ)"
SAFE_FROM="$(printf '%s' "$FROM" | tr -c 'A-Za-z0-9._-' '-')"
FNAME="${TS}-$$${RANDOM}-from-${SAFE_FROM}.md"
DEST="$DEST_DIR/$FNAME"
DATE_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Temp file lives INSIDE the destination dir (dot-prefixed so the inbox
# *.md glob never sees it): the final mv is then a same-filesystem rename,
# so readers never observe a half-written message.
TMP="$(mktemp "$DEST_DIR/.coord-send.XXXXXX" 2>/dev/null)"
[ -n "$TMP" ] || { echo "coord-send: cannot create temp file in $DEST_DIR" >&2; exit 2; }
{
echo "---"
echo "from: $FROM"
echo "to: $TARGET_LABEL"
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-send: write failed" >&2; exit 2
fi
echo "coord-send: delivered to $TARGET_LABEL ($FNAME)"
# A broadcast reaches every repo INCLUDING the sender, so mark it seen for the
# sender now: the announcing repo already knows its own news, and re-injecting
# it would burn operator attention at every session start. Keyed by the RAW
# sender name, because coord-inbox keys the seen file by the unsanitized repo
# name - using SAFE_FROM here would silently miss for names like "my repo".
# Raw means it must be a safe path component: skip rather than escape the dir.
if [ "$BROADCAST" -eq 1 ]; then
case "$FROM" in
*/*|.|..) : ;;
*) SEEN_DIR="$COORD/_broadcast/seen"
mkdir -p "$SEEN_DIR" 2>/dev/null &&
printf '%s\n' "$FNAME" >> "$SEEN_DIR/$FROM" 2>/dev/null ;;
esac
fi
# --- Reply mode: mark the original handled ---
if [ -n "$REPLY_ORIG" ]; then
"$(dirname "$0")/coord-done.sh" --repo "$FROM" "$REPLYTO" >/dev/null 2>&1
echo "coord-send: original ($REPLYTO) marked handled"
fi
exit 0