clawdie-iso/tests/mcp-boundary-test.sh

128 lines
5.8 KiB
Bash
Raw Normal View History

#!/bin/sh
# Layer 2 — mother MCP boundary test (runs on Linux; no osa, no PostgreSQL).
#
# Proves two things the mother relies on, without standing up a real mother:
# 2a) the colibri-mcp-ssh forced-command ALLOWLIST: "" and "tools" route to
# colibri-mcp; everything else is rejected (exit 1, JSON error). Tested
# both directly and through a real loopback sshd with command="..." forced.
# 2b) the MCP handshake: `colibri-mcp tools` and the stdio tools/list JSON-RPC
# return the Colibri tool catalog.
#
# The DB-backed node_register path needs real PostgreSQL and belongs on osa
# (domedog is Docker-free). This layer stops at the SSH + MCP plumbing.
#
# Requires a prebuilt colibri-mcp. Point at it with COLIBRI_MCP_BIN, else the
# colibri debug build is auto-detected.
set -u
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
WRAPPER="${WRAPPER:-${SCRIPT_DIR}/../../colibri/packaging/mother/colibri-mcp-ssh}"
# auto-detect the prebuilt binary
if [ -z "${COLIBRI_MCP_BIN:-}" ]; then
for c in "${SCRIPT_DIR}/../../colibri/target/debug/colibri-mcp" \
"${SCRIPT_DIR}/../../colibri/target/release/colibri-mcp"; do
[ -x "$c" ] && COLIBRI_MCP_BIN="$c" && break
done
fi
COLIBRI_MCP_BIN="${COLIBRI_MCP_BIN:-}"
# colibri is a sibling repo; if it isn't checked out + built, skip cleanly so a
# standalone clawdie-iso run is not a false failure. Force-fail with STRICT=1.
if [ ! -r "${WRAPPER}" ] || [ -z "${COLIBRI_MCP_BIN}" ] || [ ! -x "${COLIBRI_MCP_BIN}" ]; then
echo "SKIP: colibri not available (needs sibling colibri checkout + built colibri-mcp)."
echo " set COLIBRI_MCP_BIN / WRAPPER, or STRICT=1 to make this a failure."
[ -n "${STRICT:-}" ] && exit 2
exit 0
fi
COLIBRI_MCP_BIN=$(CDPATH= cd -- "$(dirname -- "${COLIBRI_MCP_BIN}")" && pwd)/$(basename -- "${COLIBRI_MCP_BIN}")
WORK=$(mktemp -d "${TMPDIR:-/tmp}/mcp-boundary.XXXXXX") || exit 2
SSHD_PID=""
cleanup() { [ -n "${SSHD_PID}" ] && kill "${SSHD_PID}" 2>/dev/null; rm -rf "${WORK}"; }
trap cleanup EXIT INT TERM
# Test wrapper: identical allowlist logic, but the absolute /usr/local/bin path
# is redirected to the prebuilt binary so we can run unprivileged.
TWRAP="${WORK}/colibri-mcp-ssh"
sed "s#/usr/local/bin/colibri-mcp#${COLIBRI_MCP_BIN}#g" "${WRAPPER}" >"${TWRAP}"
chmod +x "${TWRAP}"
PASS=0; FAIL=0; SKIP=0
ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; }
skip() { SKIP=$((SKIP+1)); printf ' SKIP %s\n' "$1"; }
check(){ [ "$1" -eq 0 ] && ok "$2" || bad "$2"; } # check <0|1-result> <msg>
echo "== 2b: MCP handshake (direct) =="
timeout 10 "${COLIBRI_MCP_BIN}" tools 2>/dev/null | grep -q 'colibri_status'
check $? "colibri-mcp tools lists colibri_status"
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
| timeout 10 "${COLIBRI_MCP_BIN}" 2>/dev/null | grep -q '"colibri_intake_task"'
check $? "stdio tools/list returns colibri_intake_task"
echo "== 2a: forced-command allowlist (direct wrapper) =="
# allowed: "tools" -> tool list
out=$(SSH_ORIGINAL_COMMAND="tools" timeout 10 sh "${TWRAP}" 2>/dev/null)
{ echo "${out}" | grep -q 'colibri_status'; } ; check $? 'SSH_ORIGINAL_COMMAND="tools" -> tool list'
# allowed: "" -> stdio MCP (feed a request, expect a JSON-RPC result)
out=$(printf '%s\n' '{"jsonrpc":"2.0","id":7,"method":"tools/list"}' \
| SSH_ORIGINAL_COMMAND="" timeout 10 sh "${TWRAP}" 2>/dev/null)
{ echo "${out}" | grep -q '"id":7'; } ; check $? 'SSH_ORIGINAL_COMMAND="" -> stdio MCP responds'
# rejected: arbitrary command must NOT run; exit 1 + JSON error on stderr
for evil in 'rm -rf /' 'status' 'tools; rm -rf /' 'tools --help' '/bin/sh'; do
err=$(SSH_ORIGINAL_COMMAND="${evil}" sh "${TWRAP}" 2>&1 >/dev/null); rc=$?
{ [ "${rc}" -eq 1 ] && echo "${err}" | grep -q 'rejected'; }
check $? "rejected: '${evil}' (exit 1 + json error)"
done
echo "== 2a: forced-command through a REAL loopback sshd =="
if ! command -v sshd >/dev/null 2>&1 || ! command -v ssh-keygen >/dev/null 2>&1; then
skip "sshd/ssh-keygen unavailable — direct wrapper test stands in"
else
HK="${WORK}/hostkey"; CK="${WORK}/clientkey"; AK="${WORK}/authorized_keys"
ssh-keygen -t ed25519 -N '' -f "${HK}" >/dev/null 2>&1
ssh-keygen -t ed25519 -N '' -f "${CK}" >/dev/null 2>&1
# force every connection through the wrapper, exactly like mother's authorized_keys
printf 'command="%s",restrict %s\n' "${TWRAP}" "$(cat "${CK}.pub")" >"${AK}"
chmod 600 "${AK}"
PORT=$(( (RANDOM % 5000) + 60000 ))
SSHDBIN=$(command -v sshd)
# Own config via -f so sshd ignores /etc/ssh + its (root-only) drop-ins.
CFG="${WORK}/sshd_config"
cat >"${CFG}" <<EOF
Port ${PORT}
ListenAddress 127.0.0.1
HostKey ${HK}
PidFile ${WORK}/sshd.pid
AuthorizedKeysFile ${AK}
StrictModes no
UsePAM no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
EOF
"${SSHDBIN}" -D -f "${CFG}" >"${WORK}/sshd.log" 2>&1 &
SSHD_PID=$!
sleep 1
if ! kill -0 "${SSHD_PID}" 2>/dev/null; then
skip "sshd failed to start (see log) — direct wrapper test stands in"
sed 's/^/ sshd: /' "${WORK}/sshd.log" 2>/dev/null | head -3
else
SSHOPTS="-i ${CK} -p ${PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
# allowed via forced command
# shellcheck disable=SC2086
ssh ${SSHOPTS} localhost tools 2>/dev/null | grep -q 'colibri_status'
check $? "ssh ... tools -> tool list (forced command)"
# rejected via forced command
# shellcheck disable=SC2086
out=$(ssh ${SSHOPTS} localhost 'rm -rf /' 2>&1); rc=$?
{ [ "${rc}" -ne 0 ] && echo "${out}" | grep -q 'rejected'; }
check $? "ssh ... 'rm -rf /' -> rejected (forced command)"
fi
fi
echo
echo "RESULT: ${PASS} passed, ${FAIL} failed, ${SKIP} skipped"
[ "${FAIL}" -eq 0 ] || exit 1
exit 0