fix: classify unauthorized tmux connections; stop misreporting membership

tmux answers non-allowlisted users with 'access not allowed' on stderr
and exit 0 for every command (verified 3.3a/3.5a, two users), so
has-session reports any target as existing. party status claimed
uninvited group members were joined, party leave silently 'succeeded',
and party list printed bogus '0 attendee(s)' rows. New party_conn_state
(ok/unauthorized/dead) classifies by message content; list now shows
'invite-only (ask <host>)'.
This commit is contained in:
veg 2026-07-04 09:55:47 +00:00
parent 6cac86bb15
commit f3209e39d1
3 changed files with 157 additions and 14 deletions

65
party
View file

@ -334,6 +334,31 @@ is_party_alive() {
"$PARTY_TMUX" -S "$_pa_sock" list-clients >/dev/null 2>&1
}
# Classify the caller's relationship to a party socket. Prints exactly
# one of:
# ok — live tmux server, the caller is authorized.
# unauthorized — live tmux server, but server-access rejected us.
# CAUTION, the reason this function exists: tmux
# answers a non-allowlisted user with "access not
# allowed" on stderr and EXIT STATUS 0 (verified live
# on 3.3a and 3.5a), for every command — including
# has-session, which then reports any target as
# existing. Exit codes alone cannot distinguish
# authorized from unauthorized; the message can.
# dead — nothing speaking the tmux protocol at that socket
# (stale roster, killed server, AF_UNIX impostor).
# Unknown error text with nonzero rc falls through to "dead", so string
# drift in a future tmux degrades to hidden-party behavior, never to a
# false "ok".
party_conn_state() {
_cs_err=$("$PARTY_TMUX" -S "$1" list-clients 2>&1 >/dev/null) \
&& _cs_rc=0 || _cs_rc=$?
case "$_cs_err" in
*"access not allowed"*) printf 'unauthorized\n'; return 0 ;;
esac
if [ "$_cs_rc" -eq 0 ]; then printf 'ok\n'; else printf 'dead\n'; fi
}
# FS perms
# --------
#
@ -516,7 +541,7 @@ find_party_by_name() {
set --
for rec in $(roster_list); do
roster_read "$rec" || continue
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
[ "$(party_conn_state "$RR_SOCKET")" = dead ] && continue
[ "$RR_PARTY_NAME" = "$target" ] && set -- "$@" "$rec"
done
case $# in
@ -532,7 +557,7 @@ pick_live_party() {
set --
for rec in $(roster_list); do
roster_read "$rec" || continue
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
[ "$(party_conn_state "$RR_SOCKET")" = dead ] && continue
set -- "$@" "$rec"
done
case $# in
@ -981,7 +1006,11 @@ cmd_leave() {
found=0
for rec in $(roster_list); do
roster_read "$rec" || continue
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
# Presence checks below (has-session, list-clients) are only
# meaningful on a server that accepted our connection: for an
# unauthorized caller has-session answers rc 0 for ANY target,
# which made leave "succeed" against parties we never joined.
[ "$(party_conn_state "$RR_SOCKET")" = ok ] || continue
attached=0
"$PARTY_TMUX" -S "$RR_SOCKET" has-session -t "__party_guest_$USER" 2>/dev/null \
&& attached=1
@ -1148,7 +1177,8 @@ cmd_list() {
found=0
for rec in $(roster_list); do
roster_read "$rec" || continue
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
state=$(party_conn_state "$RR_SOCKET")
[ "$state" = dead ] && continue
# Output hygiene, NOT a confidentiality control. If inherited ACLs
# widened the per-party dir past its 0750 / group=RR_GROUP mode bits,
# roster_read above has ALREADY parsed host/name/group into vars,
@ -1159,21 +1189,24 @@ cmd_list() {
# the FS perimeter alone.
list_group="${RR_GROUP:-$TMUX_PARTY_GROUP}"
id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$list_group" || continue
# Stale records (dead PID, dead socket, planted daemon) are
# filtered out by is_party_alive. They're not auto-cleaned here:
# cmd_host's mkdir-as-lock refuses to take over leftover dirs
# (auto-cleanup races with concurrent in-flight hosts). Recovery
# for a genuine crash leftover is a one-shot
# manual `rm -rf` per the host-side error message.
attendees=$("$PARTY_TMUX" -S "$RR_SOCKET" list-clients 2>/dev/null | wc -l | tr -d ' ')
# Show the group only when it differs from the env/default, to
# keep the common case uncluttered.
group_tag=
if [ -n "$RR_GROUP" ] && [ "$RR_GROUP" != "$TMUX_PARTY_GROUP" ]; then
group_tag=" [group=$RR_GROUP]"
fi
printf '%-12s %-30s %s attendee(s)%s\n' \
"$RR_HOST_USER" "$RR_PARTY_NAME" "$attendees" "$group_tag"
if [ "$state" = ok ]; then
attendees=$("$PARTY_TMUX" -S "$RR_SOCKET" list-clients 2>/dev/null | wc -l | tr -d ' ')
printf '%-12s %-30s %s attendee(s)%s\n' \
"$RR_HOST_USER" "$RR_PARTY_NAME" "$attendees" "$group_tag"
else
# Live server, connection refused: we can't count attendees
# (list-clients is behind the auth gate — its empty stdout
# used to render here as a bogus "0 attendee(s)"), but the
# party is real and the caller can ask for an invite.
printf '%-12s %-30s invite-only (ask %s)%s\n' \
"$RR_HOST_USER" "$RR_PARTY_NAME" "$RR_HOST_USER" "$group_tag"
fi
found=$((found+1))
done
@ -1257,10 +1290,14 @@ cmd_status() {
hosting='' joined=''
for rec in $(roster_list); do
roster_read "$rec" || continue
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
state=$(party_conn_state "$RR_SOCKET")
[ "$state" = dead ] && continue
if [ "$RR_HOST_USER" = "$USER" ]; then
hosting="$hosting $RR_PARTY_NAME"
fi
# The joined checks below trust has-session, which lies (rc 0
# for any target) when the server refused our connection.
[ "$state" = ok ] || continue
# Joined covers two cases. Passive: we have a client attached to
# the host's session, list-clients sees it. Active: we have our
# own __party_guest_$USER session, which survives a client detach