Files
stack/scripts/case1-accept.sh
T

707 lines
36 KiB
Bash
Executable File

#!/usr/bin/env bash
# Row 195 (#1693): the Case 1 acceptance on 10.1.1.53, design r7 section 5
# (agents/darkwing/work/queue-195/DESIGN.md). Deviations and decisions: agents/darkwing/work/queue-195/BUILD.md.
#
# case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N
#
# Root runs it on the Case 1 host after the install window, from a root-owned checkout. It reads the
# user, home, copy, node, claude, main port and tester socket marker from the args file, which must pass stack-upgrade.sh's
# rules and be a factory file for --business. Every command that runs the stack's code runs as
# `sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from /. Root itself reads ss,
# /proc, the account database and file metadata, and runs these: systemctl show and is-enabled (F1,
# P11), curl on the Console's loopback healthz (F2), sudo -l (P1, P3), sudo -u jarvis bwrap --version
# and hostcheck.sh --check (P9), and node to parse managed-settings.json (F9). F9 runs node only when
# --node is the pinned path and it and every directory above it are root's with no group or other
# write. The script changes nothing on the host but the message F3 sends and its reply, and it never
# opens claude-session.env. A bounded config/target query imports the copy only as the stack
# user, with sudo and an empty environment. Root parses its two facts in bash. P10 uses the
# fixed system Python, also as the stack user, for a read-only SO_PEERCRED observation.
# The config/probe supervisor is fixed root-validated system Bash plus setsid
# and sleep, after the uid drop: query inner/outer 5/7 s, probe 2/4 s. Root
# independently bounds its sudo wait. Legacy AS_USER talk and diagnostics below
# retain their old behavior and are NOT covered by this supervisor (F5 follow-up).
#
# One line per check, `<id> <path> <mark> <evidence>`, then `count <mark> N` for each mark and a
# result line. F checks are `pass` or `FAIL`. P checks are `closed`, `accepted <ruling>` or
# `unnamed`, which means no Q182, Q183 or Mos ruling names the path; each unnamed line goes to Mos.
#
# Exit 0 when every F check passes, 1 when one fails, 2 on usage or a refusal (a bad args file, or
# --root misused). A P check never changes the exit.
#
# Test seam, for packages/cli/tests/case1-accept.test.mjs only. Root takes none:
# --root DIR prefixes the args file, /etc, /proc, the stack's home, the host check and node, and
# reads the account database from DIR/etc/passwd and DIR/etc/group instead of getent
set -uo pipefail
usage() { echo "usage: case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N" >&2; exit 2; }
refuse() { echo "case1-accept: refusing: $*" >&2; exit 2; }
BUSINESS="" ARGS_FILE="" CLAUDE_PIN="" NODE_PIN="" ROOT=""
while [ $# -gt 0 ]; do
case "$1" in
--business) [ $# -ge 2 ] && [[ "$2" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage; BUSINESS="$2"; shift 2 ;;
--args) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] || usage; ARGS_FILE="$2"; shift 2 ;;
--claude-pin) [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; CLAUDE_PIN="$2"; shift 2 ;;
--node-pin) [ $# -ge 2 ] && [[ "$2" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; NODE_PIN="$2"; shift 2 ;;
--root) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [ -d "$2" ] || usage; ROOT="${2%/}"; shift 2 ;;
*) usage ;;
esac
done
[ -n "$BUSINESS" ] && [ -n "$ARGS_FILE" ] && [ -n "$CLAUDE_PIN" ] && [ -n "$NODE_PIN" ] || usage
if [ "$(id -u)" = 0 ]; then
[ -z "$ROOT" ] || refuse "--root is a test seam, and root takes none"
PATH=/usr/sbin:/usr/bin:/sbin:/bin
else
[ -n "$ROOT" ] || refuse "run it as root"
fi
cd / || refuse "cannot cd to /"
# --- The args file, by stack-upgrade.sh's read_args rules, plus the serve ports ---
f="$ROOT$ARGS_FILE"
[ -f "$f" ] && [ ! -L "$f" ] || refuse "no regular file $ARGS_FILE"
st="$(stat -c '%u %a' -- "$f")" || refuse "cannot stat $ARGS_FILE"
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) ||
refuse "$ARGS_FILE must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
mapfile -t ARGS < "$f" || refuse "cannot read $ARGS_FILE"
S_USER="" S_HOME="" A_BUSINESS="" NODE="" COPY_ID="" CLAUDE="" PROFILE="" PORT="" TESTER_SOCKET=0
copies=0 past=""
for ((i = 0; i < ${#ARGS[@]}; i++)); do
a="${ARGS[i]}" v="${ARGS[i + 1]:-}"
if [ -n "$past" ]; then
case "$a" in
--port) PORT="$v"; i=$((i + 1)) ;;
--business) [ "$v" = "$BUSINESS" ] || refuse "serve business differs"; i=$((i + 1)) ;;
--tester-socket) TESTER_SOCKET=$((TESTER_SOCKET + 1)) ;;
--tester-port) refuse "case1-accept.sh retires tester TCP; use --tester-socket" ;;
*) refuse "unknown serve flag $a" ;;
esac
continue
fi
case "$a" in
--) past=1 ;;
--pm) ;;
--user) S_USER="$v"; i=$((i + 1)) ;;
--home) S_HOME="$v"; i=$((i + 1)) ;;
--business) A_BUSINESS="$v"; i=$((i + 1)) ;;
--node) NODE="$v"; i=$((i + 1)) ;;
--claude) CLAUDE="$v"; i=$((i + 1)) ;;
--profile) PROFILE="$v"; i=$((i + 1)) ;;
--copy) COPY_ID="$v"; copies=$((copies + 1)); i=$((i + 1)) ;;
*) refuse "$ARGS_FILE has $a before --, which case2-unit.sh does not take" ;;
esac
done
[ "$copies" = 1 ] || refuse "$ARGS_FILE needs exactly one --copy before --, not $copies"
[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$A_BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] &&
[[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] && [[ "$CLAUDE" =~ ^/[A-Za-z0-9/._+@-]+$ ]] ||
refuse "$ARGS_FILE lacks a plain --user, --home, --business, --node, --claude or --copy"
[ "$PROFILE" = factory ] || refuse "$ARGS_FILE has --profile '${PROFILE}', not factory"
[ "$A_BUSINESS" = "$BUSINESS" ] || refuse "$ARGS_FILE is for business $A_BUSINESS, not $BUSINESS"
[[ "$PORT" =~ ^[1-9][0-9]{0,3}$ ]] && [ "$PORT" -le 1023 ] || refuse "$ARGS_FILE lacks a privileged numeric --port after --"
[ "$TESTER_SOCKET" = 1 ] || refuse "$ARGS_FILE needs one bare --tester-socket after --"
COPY="$S_HOME/.local/share/mosaic-stack/copies/$COPY_ID"
DATA_ROOT="$S_HOME/.mosaic-dev"
CRED="$S_HOME/.config/mosaic-dev/secrets/claude-session.env"
BUSINESS_FILE="$S_HOME/.config/mosaic-dev/businesses/$BUSINESS.json"
BUS_UNIT="mosaic-bus@$BUSINESS.service" CON_UNIT=mosaic-webui.service
HOSTCHECK=/usr/local/lib/mosaic-stack/hostcheck.sh
SETTINGS=/etc/claude-code/managed-settings.json
LABEL_OUTER='bwrap (enforce)' LABEL_CHILD='bwrap//&unpriv_bwrap (enforce)'
AS_USER=(sudo -u "$S_USER" -H env "PATH=$(dirname "$NODE"):/usr/local/bin:/usr/bin:/bin")
WORK="$(mktemp -d)" || refuse "cannot make a temp dir"
trap 'rm -rf -- "$WORK"' EXIT
# Row253: bounded unprivileged metadata, parsed by root without running copy code.
# row253-supervisor-begin
IFS= read -r -d '' TESTER_SUPERVISOR <<'MOSAIC_TESTER_SUPERVISOR' || :
set +m
seconds="$1" setsid_bin="$2" sleep_bin="$3"; shift 3
child="" timer="" verified=0
stat_fields() {
local pid="$1" raw="" rest
# EOF is expected: stat has no NUL. Decide on content, not read's status.
IFS= read -r -d '' raw < "/proc/$pid/stat" 2>/dev/null || :
[ -n "$raw" ] || { [ -e "/proc/$pid/stat" ] && return 1; return 3; }
rest="${raw##*) }"
[ "$rest" != "$raw" ] || return 1
read -r ST_STATE ST_PARENT ST_GROUP ST_SESSION _ <<< "$rest"
[[ "$ST_STATE" =~ ^[A-Za-z]$ && "$ST_SESSION" =~ ^[0-9]+$ && "$ST_GROUP" =~ ^[0-9]+$ ]]
}
clock_cs() {
local boot spare
read -r boot spare < /proc/uptime || return 1
[[ "$boot" =~ ^([0-9]+)\.([0-9]{2})$ ]] || return 1
CLOCK_CS=$((10#${BASH_REMATCH[1]} * 100 + 10#${BASH_REMATCH[2]}))
}
clean_session() {
local entry pid rc live until=""
while :; do
live=0
for entry in /proc/[0-9]*/stat; do
pid="${entry%/stat}"; pid="${pid##*/}"
rc=0; stat_fields "$pid" || rc=$?
[ "$rc" != 3 ] || continue
[ "$rc" = 0 ] || return 1
[ "$ST_SESSION" = "$child" ] || continue
case "$ST_STATE" in Z|X) continue ;; esac
live=1
if ! kill -KILL "$pid" 2>/dev/null; then
rc=0; stat_fields "$pid" || rc=$?
[ "$rc" = 3 ] || { [ "$rc" = 0 ] && { [ "$ST_STATE" = Z ] || [ "$ST_STATE" = X ]; }; } || return 1
fi
done
[ "$live" != 0 ] || return 0
clock_cs || return 1
# Inventory pass 1 is outside the bounded confirmation/retry budget.
[ -n "$until" ] || until=$((CLOCK_CS + 25))
[ "$CLOCK_CS" -lt "$until" ] || return 1
"$sleep_bin" 0.01
done
}
finish() {
local rc="$?"
trap - EXIT HUP INT TERM
if [ "$verified" = 1 ]; then
clean_session || rc=86
elif [ -n "$child" ]; then
kill -KILL "$child" 2>/dev/null || :
fi
[ -z "$child" ] || wait "$child" 2>/dev/null || :
if [ -n "$timer" ]; then kill -KILL "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; fi
exit "$rc"
}
trap finish EXIT
trap 'exit 88' HUP INT TERM
"$sleep_bin" "$seconds" & timer=$!
"$setsid_bin" "$@" & child=$!
# Allow session creation the whole inner budget, rather than a 200 ms margin.
while :; do
rc=0; stat_fields "$child" || rc=$?
[ "$rc" = 0 ] || exit 85
if [ "$ST_SESSION" = "$child" ]; then verified=1; break; fi
kill -0 "$timer" 2>/dev/null || exit 81
"$sleep_bin" 0.002
done
[ "$verified" = 1 ] || exit 85
while kill -0 "$child" 2>/dev/null; do
kill -0 "$timer" 2>/dev/null || exit 81
"$sleep_bin" 0.01
done
kill -0 "$timer" 2>/dev/null || exit 81
rc=0; wait "$child" || rc=$?
[ "$rc" = 0 ] || exit 87
exit 0
MOSAIC_TESTER_SUPERVISOR
# row253-supervisor-end
tester_tools() {
local input target p st magic
TS_BASH="" TS_SETSID="" TS_SLEEP="" TS_TIMEOUT="" TS_HEAD=""
for input in /bin/bash /usr/bin/setsid /usr/bin/sleep /usr/bin/timeout /usr/bin/head; do
target="$(realpath -e -- "$input" 2>/dev/null)" || return 1
case "$target" in /home/*|/root/*|/run/user/*) return 1 ;; esac
[ -f "$target" ] && [ -x "$target" ] && [ ! -L "$target" ] || return 1
p="$target"
while :; do
st="$(stat -c '%u %a' -- "$p" 2>/dev/null)" || return 1
[ "${st% *}" = 0 ] && [[ "${st#* }" =~ ^[0-7]{3,4}$ ]] && (( (8#${st#* } & 8#022) == 0 )) || return 1
[ "$p" != / ] || break
p="${p%/*}"; [ -n "$p" ] || p=/
done
magic="$(/usr/bin/od -An -tx1 -N4 -- "$target" 2>/dev/null | tr -d ' \n')"
[ "$magic" = 7f454c46 ] || return 1
case "$input" in
/bin/bash) TS_BASH="$target" ;; /usr/bin/setsid) TS_SETSID="$target" ;;
/usr/bin/sleep) TS_SLEEP="$target" ;; /usr/bin/timeout) TS_TIMEOUT="$target" ;;
/usr/bin/head) TS_HEAD="$target" ;;
esac
done
}
tester_capture_reason() {
case "$1" in
81) echo inner-timeout ;; 82) echo outer-timeout ;; 83) echo output-limit ;;
84) echo supervisor-tools ;; 85) echo inner-session ;; 86) echo inner-cleanup ;;
*) echo inner-command ;;
esac
}
tester_capture() (
local inner="$1" outer="$2" maximum="$3" user="$4" home="$5" path="$6" file notice child="" rc=0; shift 6
tester_tools || exit 84
file="$(mktemp)" || exit 88
notice="$(mktemp)" || { rm -f -- "$file"; exit 88; }
trap 'if [ -n "$child" ]; then kill -KILL -- "-$child" 2>/dev/null || :; wait "$child" 2>/dev/null || :; fi; rm -f -- "$file" "$notice"' EXIT
trap 'exit 88' HUP INT TERM
LC_ALL=C "$TS_TIMEOUT" --verbose --signal=KILL "${outer}s" "$TS_BASH" -c '
set -o pipefail
maximum="$1"; head_bin="$2"; shift 2
"$@" 2>/dev/null | "$head_bin" -c "$((maximum + 1))"
' -- "$maximum" "$TS_HEAD" sudo -u "$user" -H env -i "HOME=$home" "PATH=$path" "$TS_BASH" -c "$TESTER_SUPERVISOR" -- "$inner" "$TS_SETSID" "$TS_SLEEP" "$@" > "$file" 2> "$notice" &
child=$!
wait "$child" 2>/dev/null || rc=$?
kill -KILL -- "-$child" 2>/dev/null || :
# Only GNU timeout and the trusted head can write this root-side channel.
if [ "$rc" = 137 ] && [[ "$(<"$notice")" == *': sending signal KILL to command '* ]]; then exit 82; fi
[ "$(wc -c < "$file")" -le "$maximum" ] || exit 83
[ "$rc" = 0 ] || { case "$rc" in 81|85|86|87|88) exit "$rc" ;; *) exit 87 ;; esac; }
cat -- "$file"
)
TESTER_QUERY='import {readFileSync} from "node:fs";import {pathToFileURL} from "node:url";const m=await import(pathToFileURL(process.argv[1]).href);const s=m.loadSystem();console.log(JSON.stringify({dataRoot:s.dataRoot,socket:readFileSync(process.argv[2],"utf8").includes("MOSAIC_TESTER_SOCKET_V1")}));'
tester_facts() {
local LC_ALL=C pattern='^\{"dataRoot":"([A-Za-z0-9/._@+-]+)","socket":(true|false)\}$'
[[ "$1" =~ $pattern ]] || return 1
TS_DATA="${BASH_REMATCH[1]}" TS_MARKED="${BASH_REMATCH[2]}"
}
tester_location() {
local LC_ALL=C data="$1" home="$2" uid="$3" prefix="$4" part at="" st
TS_REASON="" TS_SOCKET=""
case "$data" in /*) ;; *) TS_REASON=not-absolute; return 1 ;; esac
case "$data" in *[!A-Za-z0-9/._@+-]*) TS_REASON=not-ascii; return 1 ;; esac
case "$data/" in */./*|*/../*|*//* ) TS_REASON=not-normalized; return 1 ;; esac
[ "${data%/}" = "$data" ] || { TS_REASON=not-normalized; return 1; }
case "$data" in "$home"/*) ;; *) TS_REASON=outside-home; return 1 ;; esac
case "$home/" in /home/*|/root/*|/run/user/*|/tmp/*|/var/tmp/*) TS_REASON=protected-private-home; return 1 ;; esac
TS_SOCKET="$data/console/tester.sock"
[ "${#TS_SOCKET}" -le 107 ] || { TS_REASON=too-long; return 1; }
local -a parts
IFS=/ read -r -a parts <<< "$data"
for part in "${parts[@]}"; do
[ -n "$part" ] || continue
at="$at/$part"
[ ! -L "$prefix$at" ] || { TS_REASON=symlink-component; return 1; }
if [ -e "$prefix$at" ]; then
[ -d "$prefix$at" ] || { TS_REASON=component-type; return 1; }
fi
done
[ "${5:-}" != observe ] || return 0
local parent="$prefix$data/console"
[ ! -L "$parent" ] || { TS_REASON=symlink-parent; return 1; }
if [ -e "$parent" ]; then
[ -d "$parent" ] || { TS_REASON=parent-type; return 1; }
st="$(stat -c '%u %a' -- "$parent")" || { TS_REASON=parent-stat; return 1; }
[ "${st% *}" = "$uid" ] || { TS_REASON=parent-owner; return 1; }
[[ "${st#* }" =~ ^[0-7]{3,4}$ ]] && (( (8#${st#* } & 8#077) == 0 )) || { TS_REASON=parent-mode; return 1; }
fi
}
# tester-location-end
# --- Output ---
MARKS=(pass FAIL closed "accepted Q182+Q183" "accepted Q183" "accepted mos-1096" unnamed)
declare -A COUNT=()
F_FAILED=0
# Text the stack's user wrote, made safe to print: printable ASCII, one line, at most 200 bytes.
clean() { tr -c '[:print:]' ' ' <<< "$1" | cut -c1-200 | sed 's/ *$//'; }
line() { local id="$1" path="$2" mark="$3"; shift 3; echo "$id $path $mark $*"; COUNT[$mark]=$(( ${COUNT[$mark]:-0} + 1 )); }
fpass() { line "$1" "$2" pass "${@:3}"; }
ffail() { line "$1" "$2" FAIL "${@:3}"; F_FAILED=1; }
# --- The account database: getent on the host, the seam's files under --root ---
db() {
if [ -n "$ROOT" ]; then
if [ $# -gt 1 ]; then awk -F: -v k="$2" '$1 == k' "$ROOT/etc/$1"; else cat "$ROOT/etc/$1"; fi
else
getent "$@"
fi
}
uid_of() { db passwd "$1" | cut -d: -f3; }
gid_of() { db passwd "$1" | cut -d: -f4; }
# The group ids USER belongs to, as `id -G USER` prints them: the primary gid, then each group
# whose member list names USER.
groups_of() {
local g; g="$(gid_of "$1")"
[ -n "$g" ] || return 0
{ echo "$g"; db group | awk -F: -v u="$1" '{ n = split($4, m, ","); for (i = 1; i <= n; i++) if (m[i] == u) print $3 }'; } | awk '!seen[$0]++'
}
names_of_groups() { local g; for g in "$@"; do db group | awk -F: -v g="$g" '$3 == g { print $1 }'; done; }
MOSAIC_UID="$(uid_of "$S_USER")"
[[ "$MOSAIC_UID" =~ ^[1-9][0-9]*$ ]] || refuse "stack account has no non-root uid"
[ "$(db passwd "$S_USER" | cut -d: -f6)" = "$S_HOME" ] || refuse "saved home differs from account home"
QUERY="$(tester_capture 5 7 4096 "$S_USER" "$S_HOME" "$(dirname "$NODE"):/usr/local/bin:/usr/bin:/bin" "$NODE" --input-type=module -e "$TESTER_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/webui/src/cli.mjs")" || refuse "tester config query $(tester_capture_reason "$?")"
tester_facts "$QUERY" || refuse "tester config query has invalid facts"
[ "$TS_MARKED" = true ] || refuse "target does not support --tester-socket"
tester_location "$TS_DATA" "$S_HOME" "$MOSAIC_UID" "$ROOT" observe || refuse "tester path refuses $TS_REASON"
TESTER_PATH="$TS_SOCKET"
# --- systemd, /proc and listeners ---
# KEY=VALUE lines for UNIT's properties.
unit_show() { local u="$1"; shift; systemctl show "$u" "${@/#/-p}" 2>/dev/null; }
prop() { sed -n "s/^$2=//p" <<< "$1" | tail -n 1; }
# The real, effective, saved and filesystem uids of PID, space-separated.
proc_uids() { awk '$1 == "Uid:" { print $2, $3, $4, $5 }' "$ROOT/proc/$1/status" 2>/dev/null; }
proc_label() { local l; l="$(cat "$ROOT/proc/$1/attr/current" 2>/dev/null)" || return 1; printf '%s' "${l%$'\n'}"; }
# One `ADDRESS PIDS` line per TCP listener on port $1, PIDS comma-separated.
listeners() {
ss -Hltnp 2>/dev/null | awk -v p="$1" '{
n = split($4, a, ":"); if (a[n] != p) next
addr = substr($4, 1, length($4) - length(p) - 1); pids = ""; s = $0
while (match(s, /pid=[0-9]+/)) { pids = pids (pids == "" ? "" : ",") substr(s, RSTART + 4, RLENGTH - 4); s = substr(s, RSTART + RLENGTH) }
print addr, pids
}'
}
# 0 when port $1 has a listener and every one is on 127.0.0.1, held by pid $2 alone.
loopback_by() {
local addr pids p any=0
[ -n "$2" ] && [ "$2" != 0 ] || return 1
while read -r addr pids; do
[ -n "$addr" ] || continue
any=1
[ "$addr" = 127.0.0.1 ] || return 1
for p in ${pids//,/ }; do [ "$p" = "$2" ] || return 1; done
[ -n "$pids" ] || return 1
done <<< "$(listeners "$1")"
[ "$any" = 1 ]
}
listener_text() { local t; t="$(listeners "$1" | tr '\n' ';')"; echo "${t:-none}"; }
BUS_SHOW="$(unit_show "$BUS_UNIT" ActiveState User MainPID)"
CON_SHOW="$(unit_show "$CON_UNIT" ActiveState User MainPID)"
CON_PID="$(prop "$CON_SHOW" MainPID)"
# --- F checks ---
check_f1() {
local u show state user pid uids why=""
for u in "$BUS_UNIT" "$CON_UNIT"; do
if [ "$u" = "$BUS_UNIT" ]; then show="$BUS_SHOW"; else show="$CON_SHOW"; fi
state="$(prop "$show" ActiveState)" user="$(prop "$show" User)" pid="$(prop "$show" MainPID)"
uids="$(proc_uids "${pid:-0}")"
[ "$state" = active ] || { why+="$u is ${state:-unknown}; "; continue; }
[ "$user" = "$S_USER" ] || { why+="$u has User=${user:-unset}; "; continue; }
[ -n "$MOSAIC_UID" ] && [ "$uids" = "$MOSAIC_UID $MOSAIC_UID $MOSAIC_UID $MOSAIC_UID" ] ||
why+="$u MainPID ${pid:-none} has uids '${uids:-unreadable}', not $S_USER's ${MOSAIC_UID:-unknown}; "
done
if [ -z "$why" ]; then fpass F1 units-user "$BUS_UNIT and $CON_UNIT are active as $S_USER (uid $MOSAIC_UID)"
else ffail F1 units-user "${why%; }"; fi
}
check_f2() {
local rc=0
curl -fsS --max-time 10 -o /dev/null "http://127.0.0.1:$PORT/healthz" 2>/dev/null || rc=$?
if [ "$rc" != 0 ]; then ffail F2 console-loopback "curl http://127.0.0.1:$PORT/healthz exited $rc"
elif ! loopback_by "$PORT" "$CON_PID"; then ffail F2 console-loopback "port $PORT listeners are '$(listener_text "$PORT")', not 127.0.0.1 by the Console's MainPID ${CON_PID:-none}"
else fpass F2 console-loopback "healthz answers, and $PORT is bound to 127.0.0.1 only, by MainPID $CON_PID"; fi
}
# F5's scan: an outer /usr/bin/bwrap owned by the stack's user with a claude descendant. Sets F5_OK
# when one pair has both labels exactly, F5_WHY to the last mismatch otherwise.
F5_OK="" F5_WHY="no /usr/bin/bwrap process of $S_USER with a claude descendant was seen while the reply was pending"
scan_f5() {
local pid ppid uids kids q c argv0 comm lo lc
local -A parent=() children=() bw=()
# One read of every PPid line and one of every exe link, so a scan stays short on a busy host.
while read -r pid ppid; do
parent[$pid]="$ppid" children[$ppid]+=" $pid"
done < <(grep -H '^PPid:' "$ROOT"/proc/[0-9]*/status 2>/dev/null | sed -n 's#^.*/proc/\([0-9]*\)/status:PPid:[[:space:]]*\([0-9]*\)$#\1 \2#p')
while read -r pid; do
[[ "$pid" =~ ^[0-9]+$ ]] && [ -n "${parent[$pid]:-}" ] && bw[$pid]=1
done < <(find "$ROOT/proc" -mindepth 2 -maxdepth 2 -name exe -lname /usr/bin/bwrap -printf '%h\n' 2>/dev/null | sed 's#.*/##')
for pid in "${!bw[@]}"; do
[ -z "${bw[${parent[$pid]}]:-}" ] || continue
uids="$(proc_uids "$pid")"
[ -n "$MOSAIC_UID" ] && [ "${uids%% *}" = "$MOSAIC_UID" ] || continue
# Every descendant, breadth first.
kids=() q=("$pid")
while [ ${#q[@]} -gt 0 ]; do
for c in ${children[${q[0]}]:-}; do kids+=("$c") q+=("$c"); done
q=("${q[@]:1}")
done
for c in "${kids[@]}"; do
argv0="$(tr '\0' '\n' < "$ROOT/proc/$c/cmdline" 2>/dev/null | sed -n 1p)"
comm="$(cat "$ROOT/proc/$c/comm" 2>/dev/null)"
[[ "${argv0##*/}" == claude* ]] || [[ "$comm" == claude* ]] || continue
lo="$(proc_label "$pid")" lc="$(proc_label "$c")"
if [ "$lo" = "$LABEL_OUTER" ] && [ "$lc" = "$LABEL_CHILD" ]; then
F5_OK="bwrap $pid reads '$lo' and its claude descendant $c reads '$lc'"; return 0
fi
F5_WHY="bwrap $pid reads '$lo' and its claude descendant $c reads '$lc', not '$LABEL_OUTER' and '$LABEL_CHILD'"
done
done
return 1
}
# F3 and F5 together: the message goes out in the background and the scan runs until it is answered.
check_f3_f5() {
local marker text rc=0 out err n tpid
marker="case1-accept-$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
text="$marker: this is the Case 1 acceptance; reply with one short line."
timeout 330 "${AS_USER[@]}" "$COPY/scripts/mosaic" talk chief "$text" --business "$BUSINESS" --tester --wait 300 > "$WORK/talk.out" 2> "$WORK/talk.err" &
tpid=$!
while :; do
scan_f5 && break
kill -0 "$tpid" 2>/dev/null || break
sleep 0.5
done
wait "$tpid" || rc=$?
# Both the sent line and the reply are on stdout; an error is on stderr.
out="$(cat "$WORK/talk.out")" err="$(cat "$WORK/talk.err")"
n="$(grep -c . <<< "$out")"
if [ "$rc" = 0 ] && grep -q "^sent [^ ]* to chief" <<< "$out" && [ "$n" -ge 2 ]; then
fpass F3 tester-reply "chief replied to $marker"
else
ffail F3 tester-reply "talk exited $rc with $n lines on stdout; last: $(clean "$(tail -n 1 <<< "${err:-$out}")")"
fi
if [ -n "$F5_OK" ]; then F5_LINE=(fpass "$F5_OK"); else F5_LINE=(ffail "$F5_WHY"); fi
}
check_f4() {
local rc=0 out
out="$("${AS_USER[@]}" "$COPY/scripts/case2-precheck.sh" --business "$BUSINESS" --profile factory 2>&1)" || rc=$?
if [ "$rc" = 0 ]; then fpass F4 precheck "case2-precheck.sh --profile factory exits 0"
else ffail F4 precheck "case2-precheck.sh --profile factory exited $rc; last: $(clean "$(tail -n 1 <<< "$out")")"; fi
}
check_f6() {
local want="/opt/mosaic-stack/claude-$CLAUDE_PIN/" v
[[ "$CLAUDE" == "$want"* ]] || { ffail F6 claude-pin "--claude $CLAUDE is not under $want"; return; }
[[ "$CLAUDE/" != */../* ]] || { ffail F6 claude-pin "--claude $CLAUDE has a .. segment"; return; }
v="$("${AS_USER[@]}" "$CLAUDE" --version 2>/dev/null | sed -n 1p)"
if [ "$v" = "$CLAUDE_PIN (Claude Code)" ]; then fpass F6 claude-pin "$CLAUDE prints '$v'"
else ffail F6 claude-pin "$CLAUDE --version prints '$(clean "$v")', not '$CLAUDE_PIN (Claude Code)'"; fi
}
# The PM check confirm runs (stack-release.sh pm_external): exactly one live launches list entry for
# launch.by, for this business. The stack's user runs the whole check and prints one result line.
F7_JS='
const {readFileSync}=require("node:fs");const {spawnSync}=require("node:child_process");
const [file,cli,b]=process.argv.slice(1);
let by;
try{by=JSON.parse(readFileSync(file,"utf8")).launch?.by}catch(e){console.log(`fail cannot read ${file}: ${e.code??"not JSON"}`);process.exit(0)}
if(typeof by!=="string"||!by){console.log("fail the business file has no launch.by");process.exit(0)}
const r=spawnSync(cli,["launches","list","--json"],{encoding:"utf8"});
if(r.status!==0){console.log(`fail launches list exited ${r.status}`);process.exit(0)}
let l;try{l=JSON.parse(r.stdout)}catch{console.log("fail launches list printed no JSON");process.exit(0)}
const live=Array.isArray(l)?l.filter((x)=>x&&x.instance===by&&x.live===true):[];
console.log(live.length!==1?`fail ${live.length} live launches of ${by}`:live[0].business!==b?`fail the live launch of ${by} is for ${live[0].business}`:`ok ${by} run ${live[0].run}`);'
check_f7() {
local out
out="$("${AS_USER[@]}" "$NODE" -e "$F7_JS" -- "$BUSINESS_FILE" "$COPY/scripts/mosaic" "$BUSINESS" 2>/dev/null | sed -n 1p)"
if [[ "$out" =~ ^ok\ [a-z0-9][a-z0-9-]*\ run\ [A-Za-z0-9._-]+$ ]]; then fpass F7 pm-live "one live launch: ${out#ok }"
else ffail F7 pm-live "$(clean "${out#fail }")"; fi
}
check_f8() {
local s
s="$(stat -c '%U %a %F' -- "$ROOT$CRED" 2>/dev/null)" || s="missing"
if [ ! -L "$ROOT$CRED" ] && [ "$s" = "$S_USER 600 regular file" ]; then fpass F8 credential-file "$CRED is '$s'"
else ffail F8 credential-file "$CRED is '$s', not '$S_USER 600 regular file'"; fi
}
# Empty when $1 and every directory above it, up to /, are the running user's (root's on the host) with
# no group or other write: case2-unit.sh's root_only rule for --node, checked again before root runs it.
# Otherwise the first path that breaks the rule. A symlink breaks it, because its mode is 777.
not_root_only() {
local p="$1" st
while :; do
st="$(stat -c '%u %a' -- "$ROOT$p" 2>/dev/null)" || { echo "$p (cannot stat)"; return; }
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || { echo "$p (uid ${st% *}, mode ${st#* })"; return; }
[ "$p" = / ] && return
p="$(dirname -- "$p")"
done
}
check_f9() {
local s v bad
s="$(stat -c '%U:%G %a %F' -- "$ROOT$SETTINGS" 2>/dev/null)" || s="missing"
[ ! -L "$ROOT$SETTINGS" ] && [ "$s" = "root:root 644 regular file" ] || { ffail F9 autoupdater-off "$SETTINGS is '$s', not 'root:root 644 regular file'"; return; }
# Root runs node here, so it runs only the pinned path, and only when nothing but root can replace it.
[ "$NODE" = "/opt/mosaic-stack/node-$NODE_PIN/bin/node" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: it is not the --node-pin path"; return; }
bad="$(not_root_only "$NODE")"
[ -z "$bad" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: $bad is not root's alone"; return; }
v="$("$ROOT$NODE" -e 'const s=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(JSON.stringify(s?.env?.DISABLE_AUTOUPDATER??null))' -- "$ROOT$SETTINGS" 2>/dev/null)" || v="unreadable"
if [ "$v" = '"1"' ]; then fpass F9 autoupdater-off "$SETTINGS is root:root 0644 with env.DISABLE_AUTOUPDATER \"1\""
else ffail F9 autoupdater-off "$SETTINGS env.DISABLE_AUTOUPDATER is $v, not \"1\""; fi
}
check_f10() {
local want="/opt/mosaic-stack/node-$NODE_PIN/bin/node" v
[ "$NODE" = "$want" ] || { ffail F10 node-pin "--node $NODE is not $want"; return; }
v="$("${AS_USER[@]}" "$NODE" --version 2>/dev/null | sed -n 1p)"
if [ "$v" = "$NODE_PIN" ]; then fpass F10 node-pin "$NODE prints $v"
else ffail F10 node-pin "$NODE --version prints '$(clean "$v")', not $NODE_PIN"; fi
}
check_f11() {
local d="$ROOT$DATA_ROOT/discord" found
if [ -L "$d" ]; then ffail F11 no-discord "$DATA_ROOT/discord is a symlink"; return; fi
[ -d "$d" ] || { fpass F11 no-discord "$DATA_ROOT/discord is absent"; return; }
found="$(find "$d" -maxdepth 1 -name '*.json' -printf '%f ' 2>/dev/null)"
if [ -z "$found" ]; then fpass F11 no-discord "$DATA_ROOT/discord holds no binding"
else ffail F11 no-discord "$DATA_ROOT/discord holds bindings: ${found% }"; fi
}
# --- P checks ---
check_p1() {
local out
out="$(sudo -l -U "$S_USER" 2>&1)"
if grep -q "is not allowed to run sudo" <<< "$out"; then line P1 "$S_USER-sudo" closed "sudo -l: $(clean "$(sed -n 1p <<< "$out")")"
else line P1 "$S_USER-sudo" unnamed "sudo -l: $(clean "$(tr '\n' ' ' <<< "$out")")"; fi
}
check_p2() {
local gs keys="$ROOT$S_HOME/.ssh/authorized_keys" kstate n
gs="$(groups_of "$S_USER" | tr '\n' ' ')"; gs="${gs% }"
n="$(wc -w <<< "$gs")"
if [ ! -e "$keys" ] && [ ! -L "$keys" ]; then kstate=absent
elif [ -f "$keys" ] && [ ! -L "$keys" ] && [ ! -s "$keys" ]; then kstate=empty
else kstate=present; fi
if [ "$n" = 1 ] && [ "$kstate" != present ]; then line P2 "$S_USER-groups-keys" closed "id -G is '$gs'; authorized_keys is $kstate"
else line P2 "$S_USER-groups-keys" unnamed "id -G is '$gs'; authorized_keys is $kstate"; fi
}
check_p3() {
local n
n="$(sudo -l -U localadmin 2>&1 | grep -c NOPASSWD)"
line P3 localadmin-sudo "accepted Q183" "sudo -l -U localadmin lists $n NOPASSWD lines"
}
check_p4() { line P4 pve-root-pbs "accepted Q182+Q183" "root on pve over VM 1008, and PBS backups that hold $S_HOME with the credential; this script can't see pve"; }
check_p5() {
local gs reach
if [ -z "$(uid_of jarvis)" ]; then line P5 jarvis-reach closed "no jarvis account"; return; fi
gs="$(names_of_groups $(groups_of jarvis) | tr '\n' ' ')"
reach="$(tr ' ' '\n' <<< "$gs" | grep -xE 'sudo|docker|lxd' | tr '\n' ' ')"
if [ -z "$reach" ]; then line P5 jarvis-reach closed "jarvis is in none of sudo, docker and lxd (groups: ${gs% })"
else line P5 jarvis-reach unnamed "jarvis is in ${reach% }"; fi
}
check_p6() {
local g m members bad=0
for g in sudo docker lxd; do
members="$(db group "$g" | cut -d: -f4)"
for m in ${members//,/ }; do
[ "$g" = sudo ] && [ "$m" = localadmin ] && continue
line P6 "group-$g" unnamed "$m is a member of $g"; bad=1
done
done
[ "$bad" = 1 ] || line P6 group-members closed "sudo holds only localadmin, and docker and lxd have no members"
}
check_p7() {
local found
found="$(grep -rlw -- "$S_USER" "$ROOT/etc/sudoers.d" 2>/dev/null | sed "s#^$ROOT##" | tr '\n' ' ')"
if [ -z "$found" ]; then line P7 sudoers-d closed "no file under /etc/sudoers.d names $S_USER"
else line P7 sudoers-d unnamed "files naming $S_USER: ${found% }"; fi
}
check_p8() {
local other
other="$(listeners 7331)$(listeners 7333)"
if loopback_by "$PORT" "$CON_PID" && [ -z "$other" ]; then
line P8 lan-listeners closed "$PORT is on 127.0.0.1 only by MainPID $CON_PID; no TCP listener on 7331 or 7333; tester uses $TESTER_PATH"
else
line P8 lan-listeners unnamed "$PORT: $(listener_text "$PORT"); 7333: $(listener_text 7333); 7331: $(listener_text 7331); Console MainPID ${CON_PID:-none}"
fi
}
check_p9() {
local out n missing=() rc=0 err mg others
out="$("$ROOT$HOSTCHECK" --check 2>/dev/null)"
for n in 1 2 3 4 5 6; do grep -q "^ok $n " <<< "$out" || missing+=("hostcheck item $n has no ok line")
done
err="$(sudo -u jarvis /usr/bin/bwrap --version 2>&1 >/dev/null)" || rc=$?
[ "$rc" != 0 ] && grep -q "Permission denied" <<< "$err" || missing+=("sudo -u jarvis /usr/bin/bwrap --version exited $rc without Permission denied")
mg="$(gid_of "$S_USER")"
[ -n "$mg" ] && [ -z "$(db group "$S_USER" | cut -d: -f4)" ] || missing+=("group $S_USER lists members, or $S_USER has no group")
others="$(db passwd | awk -F: -v g="$mg" -v u="$S_USER" '$4 == g && $1 != u { print $1 }' | tr '\n' ' ')"
[ -z "$others" ] || missing+=("other accounts have $S_USER's gid $mg: ${others% }")
if [ ${#missing[@]} = 0 ]; then
line P9 bwrap-userns "accepted mos-1096" "hostcheck items 1-6 ok, jarvis is denied bwrap, and group $S_USER has no other member"
else
for n in "${missing[@]}"; do line P9 bwrap-userns unnamed "$n"; done
fi
}
# P10 is an observation of mutable same-uid state, never a closure ruling.
TESTER_PEER='import socket,struct,sys
s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
try:
s.settimeout(1)
s.connect(sys.argv[1])
p=struct.unpack("3i",s.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
print(*p)
finally:
s.close()'
tester_snapshot() {
local parent="${TESTER_PATH%/*}" p s pid
[ ! -L "$ROOT$parent" ] && [ ! -L "$ROOT$TESTER_PATH" ] || return 1
p="$(stat -c '%u %a %F %d %i' -- "$ROOT$parent" 2>/dev/null)" || return 1
s="$(stat -c '%u %a %F %d %i' -- "$ROOT$TESTER_PATH" 2>/dev/null)" || return 1
[[ "$p" =~ ^$MOSAIC_UID\ 700\ directory\ [0-9]+\ [0-9]+$ ]] || return 1
[[ "$s" =~ ^$MOSAIC_UID\ 600\ socket\ [0-9]+\ [0-9]+$ ]] || return 1
pid="$(prop "$(unit_show "$CON_UNIT" MainPID)" MainPID)"
[[ "$pid" =~ ^[1-9][0-9]*$ ]] && [ "$pid" = "$CON_PID" ] || return 1
[ "$(proc_uids "$pid")" = "$MOSAIC_UID $MOSAIC_UID $MOSAIC_UID $MOSAIC_UID" ] || return 1
printf '%s|%s|%s' "$pid" "$p" "$s"
}
tester_python() {
local real bad magic
# Resolve the system entry, then validate the target and every ancestor.
[ -f "$ROOT/usr/bin/python3" ] && [ -x "$ROOT/usr/bin/python3" ] || return 1
real="$(realpath -e -- "$ROOT/usr/bin/python3" 2>/dev/null)" || return 1
[ -z "$ROOT" ] || real="${real#"$ROOT"}"
case "$real" in /home/*|/root/*|/run/user/*|"$S_HOME"/*) return 1 ;; esac
bad="$(not_root_only "$real")"; [ -z "$bad" ] || return 1
bad="$(not_root_only /usr/bin)"; [ -z "$bad" ] || return 1
magic="$(od -An -tx1 -N4 -- "$ROOT$real" 2>/dev/null | tr -d ' \n')"
[ "$magic" = 7f454c46 ] || return 1
printf '%s' "$real"
}
check_p10() {
local floor before after python peer pid uid gid why="" pattern='^([1-9][0-9]{0,9}) ([0-9]{1,10}) ([0-9]{1,10})$'
floor="$(cat "$ROOT/proc/sys/net/ipv4/ip_unprivileged_port_start" 2>/dev/null)"
[[ "$floor" =~ ^[0-9]{1,5}$ ]] && [ "$floor" -gt "$PORT" ] || why=floor
before="$(tester_snapshot)" || why=metadata
python="$(tester_python)" || why=interpreter
if [ -z "$why" ]; then
peer="$(tester_capture 2 4 128 "$S_USER" "$S_HOME" "$(dirname "$NODE"):/usr/local/bin:/usr/bin:/bin" "$python" -I -S -c "$TESTER_PEER" "$TESTER_PATH")" || why="probe-$(tester_capture_reason "$?")"
if [[ "$peer" =~ $pattern ]]; then
pid="${BASH_REMATCH[1]}" uid="${BASH_REMATCH[2]}" gid="${BASH_REMATCH[3]}"
[ "$pid" = "$CON_PID" ] && [ "$uid" = "$MOSAIC_UID" ] && [ "$gid" -le 2147483647 ] || why=peer
else [ -n "$why" ] || why=probe; fi
after="$(tester_snapshot)" || why=metadata
[ "$before" = "$after" ] || why=changed
fi
if [ -z "$why" ]; then
line P10 tester-socket-squat unnamed "boundary=observed path=$TESTER_PATH parent=0700 owner=$MOSAIC_UID socket=0600 peer_pid=$pid peer_uid=$uid floor=$floor port=$PORT; hostile same-uid substitution remains"
else
line P10 tester-socket-squat unnamed "boundary=failed reason=$why path=$TESTER_PATH MainPID=${CON_PID:-none}; hostile same-uid substitution remains"
fi
}
check_p11() {
local en before
en="$(systemctl is-enabled mosaic-hostcheck.service 2>/dev/null)"
before="$(prop "$(unit_show mosaic-hostcheck.service Before)" Before)"
if [ "$en" = enabled ] && grep -qxF -- "$BUS_UNIT" <<< "${before// /$'\n'}"; then
line P11 hostcheck-before-bus closed "mosaic-hostcheck.service is enabled, and its Before= names $BUS_UNIT"
else
line P11 hostcheck-before-bus unnamed "mosaic-hostcheck.service is '${en:-unknown}', Before='$before'; it needs enabled and $BUS_UNIT"
fi
}
check_f1
check_f2
F5_LINE=()
check_f3_f5
check_f4
"${F5_LINE[0]}" F5 bwrap-label "${F5_LINE[@]:1}"
check_f6
check_f7
check_f8
check_f9
check_f10
check_f11
check_p1; check_p2; check_p3; check_p4; check_p5; check_p6
check_p7; check_p8; check_p9; check_p10; check_p11
for m in "${MARKS[@]}"; do echo "count $m ${COUNT[$m]:-0}"; done
if [ "$F_FAILED" = 0 ]; then echo "case1-accept: every F check passes; send the whole output to Duckworth, and each unnamed line to Mos"; exit 0; fi
echo "case1-accept: an F check failed, so the row fails"
exit 1