Files
ai-sandbox/tests/run.sh
T
ducoterra ed2eb462d6 share the host ~/.agents read-only, so skills come along
A sandboxed harness found none of your skills, prompt templates or
standing AGENTS.md, because those live in ~/.agents and only the project
was mounted. It is now bind-mounted at /home/sandbox/.agents by default
-- bound, never copied -- with --no-agents to opt out and --agents-dir
for a curated set, reported by --print-policy and by doctor.

Read-only is the load-bearing part and has no escape hatch: a skill is an
instruction the model follows later, on the host, so a sandbox that could
write ~/.agents had a persistence path out of itself, planted as a
helpful-looking SKILL.md. It holds even under --insecure-keep-root, the
mount lands after /home/sandbox itself so it cannot be shadowed by the
home volume or tmpfs, and an explicit --mount on the same destination
wins because podman refuses duplicates. A missing or unreadable directory
is a silent skip like the pi bridge's; an explicit --agents-dir that
resolves to nothing is a hard error.

The exposure is stated in docs/threat-model.md: the tree, its .git history
and anything pasted into a skill are readable by whatever runs in there.

tests/unit/09_agents.sh covers resolution, precedence and the
bind-never-copy canary; tests/e2e/12_sandbox.sh mounts it in a real
container, checks the host tree survives a run byte for byte and that
even root inside the sandbox cannot write it.

LLM-Generated: true
2026-10-27 20:30:53 -04:00

103 lines
4.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# tests/run.sh -- the ai-sandbox test runner.
#
# ./tests/run.sh unit tests (no containers started, no network)
# ./tests/run.sh --e2e everything, including real containers and egress tests
# ./tests/run.sh --only 04 just the files matching 04
# ./tests/run.sh --verbose show each test file's raw output as it runs
#
# Every test file runs with its own XDG state so nothing here touches the real
# machine's config, state or cache directories.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(dirname "$HERE")"
AISB="${AISB:-$ROOT/bin/ai-sandbox}"
export AISB
WITH_E2E=0 ONLY="" VERBOSE=0
while (($#)); do
case "$1" in
--e2e|-e) WITH_E2E=1; shift ;;
--only) ONLY="${2:-}"; shift 2 ;;
--verbose|-v) export VERBOSE=1; shift ;; # lib.sh reads VERBOSE
--help|-h) sed -n '2,12p' "$0"; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
done
command -v podman >/dev/null || { echo "podman is required to run the tests" >&2; exit 2; }
# Syntax-check every shell file in the project before running anything.
SYNTAX_BAD=0
for f in "$AISB" "$HERE"/*.sh "$HERE"/unit/*.sh "$HERE"/e2e/*.sh "$ROOT"/install.sh; do
[[ -f "$f" ]] || continue
if ! syn="$(bash -n "$f" 2>&1)"; then
echo "SYNTAX: $f" >&2; printf '%s\n' "$syn" | sed 's/^/ /' >&2; SYNTAX_BAD=1
fi
done
(( SYNTAX_BAD )) && exit 2
FILES=( "$HERE"/unit/*.sh )
(( WITH_E2E )) && FILES+=( "$HERE"/e2e/*.sh )
if [[ -n "$ONLY" ]]; then
mapfile -t FILES < <(printf '%s\n' "${FILES[@]}" | grep "/[^/]*$ONLY")
fi
BASE="$(mktemp -d "${TMPDIR:-/tmp}/ai-sandbox-tests.XXXXXX")"
COUNTS="$BASE/counts"
: > "$COUNTS"
trap 'chmod -R u+rwX "$BASE" 2>/dev/null; rm -rf "$BASE" 2>/dev/null' EXIT
echo "ai-sandbox test suite"
echo " script : $AISB"
echo " mode : $([[ $WITH_E2E == 1 ]] && echo 'unit + e2e (real containers)' || echo 'unit only (add --e2e for containers)')"
echo " files : ${#FILES[@]}"
echo
FAILED_FILES=()
for f in "${FILES[@]}"; do
name="$(basename "$f")"
W="$BASE/$name"; mkdir -p "$W"/{state,cache,cfg}
echo "=================================================================="
echo "== $name"
echo "=================================================================="
# shellcheck disable=SC1090
(
export AISB_STATE_DIR="$W/state" AISB_CACHE_DIR="$W/cache" AISB_CONFIG_DIR="$W/cfg"
export WORK="$W" TESTS="$HERE" AISB_TEST_COUNTS="$COUNTS"
# Unit files must not touch a registry: pins come from the seeded cache and the
# digest lookup is a stub. e2e files keep the real behaviour.
case "$f" in
*/unit/*) export AISB_BASE_AUTO_UPDATE="${AISB_BASE_AUTO_UPDATE:-0}" \
AISB_BASE_PULL_FALLBACK="${AISB_BASE_PULL_FALLBACK:-0}" ;;
esac
# Never bridge the developer's own ~/.pi/agent into a test: tests that exercise the
# bridge point AISB_PI_AGENT_DIR at a fixture of their own. Same for a real token.
export AISB_PI_AGENT_DIR="${AISB_PI_AGENT_DIR:-$W/absent-pi-agent-dir}"
# Same for ~/.agents: its default is the developer's own skills directory, which no
# test should depend on or accidentally share. Files that exercise the .agents
# bridge point AISB_AGENTS_DIR at a fixture of their own.
export AISB_AGENTS_DIR="${AISB_AGENTS_DIR:-$W/absent-agents-dir}"
unset AI_API_TOKEN AISB_PI_TOKEN_ENV AISB_PI_CONFIG AISB_PI_TOKEN_AUTO
# NOTE: HOME is deliberately not overridden -- podman storage lives there.
unset AISB_ALLOW AISB_ENV AISB_MOUNT AISB_INSTALL AISB_NET AISB_EXTRA_ARGS AISB_SESSION_OVERRIDE
unset AISB_AGENTS AISB_AGENTS_DEST
source "$f"
summary
) || FAILED_FILES+=("$name")
echo
done
TOTAL_P=0 TOTAL_F=0
while read -r p fl; do TOTAL_P=$((TOTAL_P + p)); TOTAL_F=$((TOTAL_F + fl)); done < "$COUNTS"
echo "=================================================================="
if ((${#FAILED_FILES[@]})); then
printf '\033[31mFAILED\033[0m %d passed, %d failed across %d file(s)\n' "$TOTAL_P" "$TOTAL_F" "${#FAILED_FILES[@]}"
printf ' failing files: %s\n' "${FAILED_FILES[*]}"
exit 1
fi
printf '\033[32mALL PASS\033[0m %d assertions, %d file(s), %s mode\n' \
"$TOTAL_P" "${#FILES[@]}" "$([[ $WITH_E2E == 1 ]] && echo unit+e2e || echo unit)"