From c3bffbd55760c48107fc3749d752f2266eff9127 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 04:19:49 -0400 Subject: [PATCH] feat(13-09): widen same-host deploy guard from containment-only to any mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- scripts/deploy-to-target.sh | 21 +--- scripts/lib/common.sh | 42 +++++++ .../deploy-guard-same-host.sh | 104 ++++++++++++++++++ 3 files changed, 152 insertions(+), 15 deletions(-) create mode 100755 tests/production-quality/deploy-guard-same-host.sh diff --git a/scripts/deploy-to-target.sh b/scripts/deploy-to-target.sh index 878b4ea8..7fa2a65c 100755 --- a/scripts/deploy-to-target.sh +++ b/scripts/deploy-to-target.sh @@ -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 diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index e89637ba..df1ce0c2 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -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 +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. diff --git a/tests/production-quality/deploy-guard-same-host.sh b/tests/production-quality/deploy-guard-same-host.sh new file mode 100755 index 00000000..5fec21ff --- /dev/null +++ b/tests/production-quality/deploy-guard-same-host.sh @@ -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 ]