feat(13-09): widen same-host deploy guard from containment-only to any mismatch

Adds assert_safe_same_host_deploy(local_src, remote_dst) to lib/common.sh:
pure, no SSH inside it, callable directly from a test with fixed inputs.
Returns 0 only when the two already-resolved paths are equal; refuses
(non-zero, message naming both paths + the 2026-07-31 incident) on any
other same-host mismatch.

This closes a real gap in the 2026-07-31 incident's original fix: the old
guard's two `case` blocks refused only containment (source-in-destination
or destination-in-source). A SIBLING directory — for example this very
worktree, archy-phase13, deploying onto TARGET_DIR's resolved symlink
target (archy, the main checkout) — is neither contained by nor containing
of the destination, so the old guard let it through and `rsync --delete`
would have mirrored the sibling onto the main checkout, deleting everything
the sibling lacks. Found while retargeting deploy-to-target.sh for D-19,
not a D-19 effect itself.

deploy-to-target.sh's guard block now calls assert_safe_same_host_deploy
instead of the two inline containment-only case blocks (old logic removed,
not left dead alongside the new call).

tests/production-quality/deploy-guard-same-host.sh pins all five
<behavior> cases (identical/contained/containing/sibling/unrelated)
against the function with no SSH, no rsync, no real deploy — including the
exact archy-phase13-vs-archy pair as the sibling-directory regression pin.
Manually confirmed non-vacuous: flipping the sibling fixture's expectation
to "allow" makes the test fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-04 04:19:49 -04:00
co-authored by Claude Opus 5
parent 073bf6f33d
commit c3bffbd557
3 changed files with 152 additions and 15 deletions
+6 -15
View File
@@ -576,26 +576,17 @@ section_start
# worktree destroyed mid-run, a running dev server killed, and two concurrent
# sessions' uncommitted work lost permanently.
#
# Refuse the deploy when source and destination are on the same host AND either
# path contains the other. Identical paths are allowed (the normal local case).
# Refuse the deploy when source and destination are on the same host and
# their resolved paths differ AT ALL — not just when one contains the
# other. See assert_safe_same_host_deploy in lib/common.sh for the full
# incident history and the sibling-directory gap this widening closes.
_LOCAL_SRC="$(readlink -f "$PROJECT_DIR")"
_REMOTE_ID="$(ssh $SSH_OPTS "$TARGET_HOST" 'cat /etc/machine-id 2>/dev/null' 2>/dev/null || true)"
_LOCAL_ID="$(cat /etc/machine-id 2>/dev/null || true)"
if [ -n "$_REMOTE_ID" ] && [ "$_REMOTE_ID" = "$_LOCAL_ID" ]; then
_REMOTE_DST="$(ssh $SSH_OPTS "$TARGET_HOST" "readlink -f '$TARGET_DIR'" 2>/dev/null || true)"
if [ -n "$_REMOTE_DST" ] && [ "$_REMOTE_DST" != "$_LOCAL_SRC" ]; then
case "$_LOCAL_SRC/" in
"$_REMOTE_DST"/*)
echo "FATAL: refusing to deploy. Source '$_LOCAL_SRC' is INSIDE the deploy destination '$_REMOTE_DST' on this same host." >&2
echo " 'rsync --delete' would delete the destination's contents (including this source) — the 2026-07-31 data-loss incident." >&2
echo " Run this script from the main checkout ($_REMOTE_DST), not from a worktree or copy." >&2
exit 1 ;;
esac
case "$_REMOTE_DST/" in
"$_LOCAL_SRC"/*)
echo "FATAL: refusing to deploy. Destination '$_REMOTE_DST' is INSIDE the source '$_LOCAL_SRC' on this same host." >&2
exit 1 ;;
esac
if [ -n "$_REMOTE_DST" ]; then
assert_safe_same_host_deploy "$_LOCAL_SRC" "$_REMOTE_DST" || exit 1
fi
fi
+42
View File
@@ -157,6 +157,48 @@ wait_for_container() {
return 1
}
# ── Same-host deploy safety guard (2026-07-31 incident, widened 13-09) ──
# Refuse a same-host deploy whose resolved source and destination differ.
#
# The 2026-07-31 incident: a same-host `rsync --delete` deploy whose source
# was INSIDE the destination (a worktree under the main checkout) mirrored
# the worktree onto the main checkout and deleted ~1810 tracked files, a
# running dev server, and two sessions' uncommitted work. The original fix
# refused only that containment shape (source-in-destination or
# destination-in-source). It missed SIBLING directories that share a parent
# but neither contains the other — e.g. archy-phase13 (this worktree) as
# source and archy (the main checkout, TARGET_DIR's resolved symlink
# target) as destination — which is the identical rsync --delete hazard
# through a shape the old two-case guard let through.
#
# This function takes two ALREADY-RESOLVED (`readlink -f`) absolute paths
# and makes no SSH calls itself — same-host detection stays in the caller
# (deploy-to-target.sh already does it via /etc/machine-id). It returns 0
# only when the two paths are equal; every other case is refused, not just
# the two containment shapes. Any resolved-path mismatch on the same host
# is the same rsync --delete hazard regardless of shape, so "refuse unless
# equal" is both the widening and a simplification.
#
# Usage: assert_safe_same_host_deploy <local_src> <remote_dst>
assert_safe_same_host_deploy() {
local local_src="$1" remote_dst="$2"
if [ -z "$local_src" ] || [ -z "$remote_dst" ]; then
log_error "assert_safe_same_host_deploy: both paths are required (local_src='$local_src' remote_dst='$remote_dst')"
return 1
fi
if [ "$local_src" = "$remote_dst" ]; then
return 0
fi
echo "FATAL: refusing to deploy. Source '$local_src' and destination '$remote_dst' are on the same host but resolve to DIFFERENT paths." >&2
echo " 'rsync --delete' would mirror one onto the other and delete whatever the source lacks — the 2026-07-31 data-loss incident (and the sibling-directory shape its original fix missed)." >&2
echo " Run this script from the deploy destination itself ('$remote_dst'), not from a worktree, sibling checkout, or copy." >&2
return 1
}
# ── Section timing ─────────────────────────────────────────────────────
# Track elapsed time for deploy sections.
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# deploy-guard-same-host.sh — regression pin for assert_safe_same_host_deploy
# (scripts/lib/common.sh), widened in 13-09 from containment-only to any
# resolved-path mismatch on the same host.
#
# The 2026-07-31 incident: a same-host `rsync --delete` deploy whose source
# was INSIDE the destination mirrored the source onto the destination and
# deleted ~1810 tracked files, a running dev server, and two sessions'
# uncommitted work. The original fix refused only containment (source-in-
# destination or destination-in-source). It missed SIBLING directories that
# share a parent but neither contains the other — e.g. this session's own
# worktree topology, archy-phase13 (source) vs archy (the main checkout,
# TARGET_DIR's resolved symlink target) — which is the identical rsync
# --delete hazard through a shape the old two-case guard let through.
#
# No SSH, no rsync, no real deploy — pure fixture strings against the
# function. Usage: ./deploy-guard-same-host.sh (takes no host argument)
# Exit 0 = all assertions pass.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
# shellcheck source=../../scripts/lib/common.sh
source "$PROJECT_DIR/scripts/lib/common.sh"
PASS=0; FAIL=0
say() { printf '%s\n' "$*"; }
ok() { PASS=$((PASS+1)); say " PASS: $1"; }
bad() { FAIL=$((FAIL+1)); say " FAIL: $1"; }
# Helper: run assert_safe_same_host_deploy and check its exit code against
# an expectation ("allow" or "refuse"), silencing its stderr message so the
# test output stays readable.
check() {
local desc="$1" src="$2" dst="$3" expect="$4"
local rc
assert_safe_same_host_deploy "$src" "$dst" >/dev/null 2>&1
rc=$?
if [ "$expect" = "allow" ]; then
[ "$rc" -eq 0 ] && ok "$desc" || bad "$desc (expected allow/exit 0, got exit $rc)"
else
[ "$rc" -ne 0 ] && ok "$desc" || bad "$desc (expected refuse/non-zero, got exit $rc)"
fi
}
say "== assert_safe_same_host_deploy — fixture matrix =="
# 1) Identical resolved source and destination: allowed. The normal
# in-place deploy from the main checkout onto its own symlinked
# destination.
check "identical resolved paths are allowed" \
"/home/archipelago/Projects/archy" \
"/home/archipelago/Projects/archy" \
"allow"
# 2) Source is inside (a subdirectory of) the destination: refused. The
# original 2026-07-31 containment case.
check "source-inside-destination is refused" \
"/home/archipelago/Projects/archy/.claude/worktrees/some-agent" \
"/home/archipelago/Projects/archy" \
"refuse"
# 3) Destination is inside the source: refused. The mirror-image
# containment case.
check "destination-inside-source is refused" \
"/home/archipelago/Projects/archy" \
"/home/archipelago/Projects/archy/.claude/worktrees/some-agent" \
"refuse"
# 4) Sibling-directory regression pin — the exact shape this session's own
# worktree topology exhibits, and the gap the old two-case guard let
# through: archy-phase13 (this worktree) as source, archy (the main
# checkout, TARGET_DIR's resolved symlink target) as destination. Share
# a parent (/home/archipelago/Projects); neither contains the other.
check "sibling directories (archy-phase13 vs archy) are refused [SIBLING REGRESSION PIN]" \
"/home/archipelago/Projects/archy-phase13" \
"/home/archipelago/Projects/archy" \
"refuse"
# 5) Two completely unrelated same-host paths with no shared parent at
# all: refused. Same-host plus any mismatch is refused, not just the
# two containment shapes.
check "unrelated paths with no shared parent are refused" \
"/home/archipelago/Projects/archy" \
"/opt/archipelago/web-ui" \
"refuse"
# 6) A refused case names both resolved paths and the 2026-07-31 incident
# on stderr, so a future operator understands why rather than
# reflexively retrying with a force flag.
MSG="$(assert_safe_same_host_deploy "/home/archipelago/Projects/archy-phase13" "/home/archipelago/Projects/archy" 2>&1 >/dev/null)"
if echo "$MSG" | grep -q '/home/archipelago/Projects/archy-phase13' \
&& echo "$MSG" | grep -q '/home/archipelago/Projects/archy' \
&& echo "$MSG" | grep -q '2026-07-31'; then
ok "refusal message names both resolved paths and the 2026-07-31 incident"
else
bad "refusal message missing an expected component: $MSG"
fi
say ""
say "== ${PASS} passed, ${FAIL} failed =="
[ "$FAIL" -eq 0 ]