Repositories / jai.git

jai.git

Clone (read-only): git clone http://git.guha-anderson.com/git/jai.git

Branch

import a bunch of AI-generated tests

Author
David Mazieres <dm@uun.org>
Date
2026-03-27 00:44:47 -0700
Commit
b991148198aa733fb51e9f827be3b21e5029062b
.gitignore
index 8bceb6a..5208d6c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,9 @@
-# generated
 *.o
 *~
 .*~
 .cache
-/.deps
-/Makefile
-/Makefile.in
+.deps
+.dirstamp
 /aclocal.m4
 /autom4te.cache/
 /config.h
@@ -19,6 +17,9 @@
 /jai-*.tar.gz
 /jai.1
 /jai.conf
+/jai.suid
 /missing
 /stamp-h1
+Makefile
+Makefile.in
 compile_commands.json
AUTHORS
index e446123..098cacf 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -1 +1,3 @@
 This program was hand-written by David Mazieres.
+
+The tests were written by AI.
INSTALL
index ce2d6b8..e99eb5b 100644
--- a/INSTALL
+++ b/INSTALL
@@ -29,3 +29,13 @@ To override the installation prefix:
     ./configure --prefix=/usr
     make
     sudo make install
+
+Most of the tests require a setuid root version of jai to test.  Since
+people's build directories are often mounted nosuid, make `jai.suid`
+in the top of your build directory a symbolic link to a place with a
+setuid executable.  You can run:
+
+    sudo test/setup-setuid.sh
+
+to do this automatically in on /var/tmp, which is generally mounted
+setuid capable.
Makefile.am
index d57873f..46af677 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1,3 +1,5 @@
+SUBDIRS = tests
+
 bin_PROGRAMS = jai
 
 AM_CXXFLAGS = $(MOUNT_CFLAGS) $(LIBACL_CFLAGS)
@@ -20,10 +22,10 @@ bashcomp_DATA = bash-completion/jai
 
 EXTRA_DIST = jai.1 jai.1.md jai.conf.in bash-completion/jai logo.svg
 
-CLEANFILES = *~
+CLEANFILES = *~ jai.suid
 DISTCLEANFILES = jai.conf
 
-distclean-local:
+maintainer-clean-local:
 	-rm -rf .deps
 
 MAINTAINERCLEANFILES = \
configure.ac
index 2e04942..17fe4b3 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1,6 +1,6 @@
 AC_INIT([jai], [0.1], [https://www.scs.stanford.edu/~dm/addr/],,
         https://github.com/stanford-scs/jai)
-AM_INIT_AUTOMAKE([foreign -Wall -Werror])
+AM_INIT_AUTOMAKE([foreign -Wall -Werror serial-tests])
 AC_CONFIG_HEADERS([config.h])
 
 : ${CXXLANG=-std=gnu++23}
@@ -39,5 +39,11 @@ AC_SUBST([UNTRUSTED_USER])
 AC_DEFINE_UNQUOTED([UNTRUSTED_USER], ["$UNTRUSTED_USER"],
   [Username for the sandboxed untrusted user])
 
-AC_CONFIG_FILES([Makefile jai.conf])
+TEST_ABS_TOP_BUILDDIR=`pwd -P`
+TEST_ABS_TOP_SRCDIR=`cd "$srcdir" && pwd -P`
+AC_SUBST([TEST_ABS_TOP_BUILDDIR])
+AC_SUBST([TEST_ABS_TOP_SRCDIR])
+
+AC_CONFIG_FILES([Makefile tests/Makefile jai.conf tests/common.sh tests/setup-setuid.sh])
+AC_CONFIG_COMMANDS([test-scripts], [chmod +x tests/common.sh tests/setup-setuid.sh])
 AC_OUTPUT
tests/.gitignore
new file mode 100644
index 0000000..a88f766
--- /dev/null
+++ b/tests/.gitignore
@@ -0,0 +1,5 @@
+/common.sh
+/jai_test_probe
+/jai_test_pty_driver
+/options_test
+/setup-setuid.sh
tests/Makefile.am
new file mode 100644
index 0000000..50a038e
--- /dev/null
+++ b/tests/Makefile.am
@@ -0,0 +1,40 @@
+AUTOMAKE_OPTIONS = subdir-objects
+
+TESTS = \
+		options_test \
+		basic.sh \
+		casual-overlay.sh \
+		config-dir-hidden.sh \
+		config-precedence.sh \
+		home-cwd-guard.sh \
+		jail-files.sh \
+		job-control.sh \
+		job-control-same-pgrp-stop.sh \
+		job-control-sigtstp-fallback.sh \
+		job-control-sigtstp-preserve.sh \
+		modes.sh \
+		mask.sh \
+		process-state.sh \
+		strict-home-grant.sh \
+		sudo-env.sh \
+		storage.sh \
+		storage-from-conf.sh \
+		teardown.sh
+
+check_PROGRAMS = jai_test_probe jai_test_pty_driver options_test
+
+jai_test_probe_SOURCES = jai-test-probe.cc
+jai_test_pty_driver_SOURCES = jai-test-pty-driver.cc
+options_test_SOURCES = options_test.cc ../options.cc ../options.h
+
+EXTRA_DIST = $(TESTS) common.sh.in setup-setuid.sh.in
+
+CLEANFILES = *~
+
+maintainer-clean-local:
+	-rm -rf .deps
+
+MAINTAINERCLEANFILES = $(srcdir)/Makefile.in
+
+all-local:
+	chmod +x $(builddir)/setup-setuid.sh
tests/basic.sh
new file mode 100755
index 0000000..41a61b2
--- /dev/null
+++ b/tests/basic.sh
@@ -0,0 +1,32 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test basic
+init_config
+
+assert_path_exists "$CONFIG_DIR/.defaults"
+assert_path_exists "$CONFIG_DIR/default.conf"
+assert_path_exists "$CONFIG_DIR/default.jail"
+
+capture run_jai --version
+assert_status 0
+assert_contains "$CAPTURE_STDOUT" "jai 0.1"
+assert_contains "$CAPTURE_STDOUT" "https://github.com/stanford-scs/jai"
+
+capture run_jai --print-defaults
+assert_status 0
+cmp -s "$CONFIG_DIR/.defaults" "$CAPTURE_OUT_FILE" ||
+  fail "--print-defaults output does not match .defaults"
+
+cat >"$CONFIG_DIR/alt.conf" <<'EOF'
+conf .defaults
+mode bare
+jail alt-name
+command /usr/bin/env
+EOF
+
+capture run_jai -D -C alt
+assert_status 0
+assert_contains "$CAPTURE_STDOUT" "JAI_MODE=bare"
+assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=alt-name"
tests/casual-overlay.sh
new file mode 100755
index 0000000..88f98ed
--- /dev/null
+++ b/tests/casual-overlay.sh
@@ -0,0 +1,40 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test casual-overlay
+init_config
+
+HOST_HOME_FILE=$REAL_HOME/jai-test-home-$$
+CWD_FILE=$WORKDIR/granted.txt
+UPPER_FILE=$CONFIG_DIR/default.changes/$(basename "$HOST_HOME_FILE")
+
+register_cleanup_path "$HOST_HOME_FILE"
+
+printf 'host-home' >"$HOST_HOME_FILE"
+printf 'host-cwd' >"$CWD_FILE"
+
+capture_in_dir "$WORKDIR" run_jai /bin/sh -c '
+  printf "%s\n%s\n" "$JAI_MODE" "$JAI_JAIL"
+  printf overlay > "$1"
+  printf granted > "$2"
+  printf private > /tmp/private-file
+  cat /var/tmp/private-file
+' sh "$HOST_HOME_FILE" "$CWD_FILE"
+
+assert_status 0
+assert_contains "$CAPTURE_STDOUT" "casual"
+assert_contains "$CAPTURE_STDOUT" "default"
+assert_contains "$CAPTURE_STDOUT" "private"
+assert_file_equals "$HOST_HOME_FILE" "host-home"
+assert_file_equals "$CWD_FILE" "granted"
+assert_path_exists "$UPPER_FILE"
+assert_file_equals "$UPPER_FILE" "overlay"
+
+mount_line=$(get_mount_line "/run/jai/$REAL_USER/default.home")
+assert_contains "$mount_line" "upperdir=$CONFIG_DIR/default.changes"
+assert_root_file_equals "/run/jai/$REAL_USER/tmp/default/private-file" "private"
+
+capture_in_dir "$WORKDIR" run_jai /bin/sh -c 'printf denied >/etc/jai-test-denied'
+assert_status 1
+assert_contains "$CAPTURE_STDERR" "Read-only file system"
tests/common.sh.in
new file mode 100644
index 0000000..19b152a
--- /dev/null
+++ b/tests/common.sh.in
@@ -0,0 +1,376 @@
+#!/bin/sh
+
+set -eu
+
+ABS_TOP_BUILDDIR='@TEST_ABS_TOP_BUILDDIR@'
+ABS_TOP_SRCDIR='@TEST_ABS_TOP_SRCDIR@'
+JAI_BUILD_BIN=$ABS_TOP_BUILDDIR/jai
+JAI_SUID_BIN=$ABS_TOP_BUILDDIR/jai.suid
+JAI_BIN=$JAI_BUILD_BIN
+JAI_TEST_PROBE=$ABS_TOP_BUILDDIR/tests/jai_test_probe
+JAI_TEST_PTY_DRIVER=$ABS_TOP_BUILDDIR/tests/jai_test_pty_driver
+UNTRUSTED_GECOS='JAI sandbox untrusted user'
+UNTRUSTED_USER='@UNTRUSTED_USER@'
+CURRENT_UID=$(id -u)
+CURRENT_USER=$(id -un)
+REAL_USER=
+REAL_UID=
+REAL_HOME=
+TEST_PRIVILEGE_MODE=
+
+TEST_ROOT=
+CONFIG_DIR=
+WORKDIR=
+CLEANUP_PATHS=
+CAPTURE_STATUS=
+CAPTURE_STDOUT=
+CAPTURE_STDERR=
+CAPTURE_OUT_FILE=
+CAPTURE_ERR_FILE=
+
+skip() {
+  printf 'SKIP: %s\n' "$*" >&2
+  exit 77
+}
+
+fail() {
+  printf 'FAIL: %s\n' "$*" >&2
+  exit 1
+}
+
+ensure_current_build_jai() {
+  [ -x "$JAI_BUILD_BIN" ] ||
+    fail "$JAI_BUILD_BIN has not been built; run make first"
+
+  status=0
+  make -C "$ABS_TOP_BUILDDIR" -q jai >/dev/null 2>&1 || status=$?
+  if [ "$status" -eq 0 ]; then
+    return
+  fi
+  [ "$status" -eq 1 ] || fail "make -C $ABS_TOP_BUILDDIR -q jai failed"
+  make -C "$ABS_TOP_BUILDDIR" jai >/dev/null
+}
+
+resolve_real_identity() {
+  [ -n "$REAL_USER" ] && return
+
+  if [ -n "${JAI_TEST_USER:-}" ]; then
+    REAL_USER=$JAI_TEST_USER
+  elif [ "$CURRENT_UID" -eq 0 ]; then
+    if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
+      REAL_USER=$SUDO_USER
+    else
+      owner_uid=$(stat -c %u "$ABS_TOP_BUILDDIR") ||
+        fail "cannot stat $ABS_TOP_BUILDDIR"
+      if [ "$owner_uid" -eq 0 ]; then
+        owner_uid=$(stat -c %u "$ABS_TOP_SRCDIR") ||
+          fail "cannot stat $ABS_TOP_SRCDIR"
+      fi
+      [ "$owner_uid" -ne 0 ] ||
+        fail "running tests as root requires JAI_TEST_USER or a non-root build/src directory owner"
+      owner_entry=$(getent passwd "$owner_uid") ||
+        fail "cannot find password entry for uid $owner_uid"
+      REAL_USER=$(printf '%s\n' "$owner_entry" | cut -d: -f1)
+    fi
+  else
+    REAL_USER=$CURRENT_USER
+  fi
+
+  entry=$(getent passwd "$REAL_USER") ||
+    fail "cannot find password entry for $REAL_USER"
+  REAL_UID=$(printf '%s\n' "$entry" | cut -d: -f3)
+  REAL_HOME=$(printf '%s\n' "$entry" | cut -d: -f6)
+}
+
+find_setuid_jai() {
+  if [ -u "$JAI_SUID_BIN" ] && [ -r "$JAI_SUID_BIN" ] && [ -x "$JAI_SUID_BIN" ] &&
+     cmp -s "$JAI_BUILD_BIN" "$JAI_SUID_BIN" 2>/dev/null; then
+    printf '%s\n' "$JAI_SUID_BIN"
+    return 0
+  fi
+  return 1
+}
+
+configure_privilege_mode() {
+  [ -n "$TEST_PRIVILEGE_MODE" ] && return
+
+  resolve_real_identity
+  requested_mode=${JAI_TEST_PRIVILEGE_MODE:-auto}
+
+  case $requested_mode in
+    auto)
+      if [ "$CURRENT_UID" -eq 0 ]; then
+        TEST_PRIVILEGE_MODE=root
+        JAI_BIN=$JAI_BUILD_BIN
+      elif setuid_jai=$(find_setuid_jai); then
+        TEST_PRIVILEGE_MODE=setuid
+        JAI_BIN=$setuid_jai
+      else
+        skip "need a root shell or a current $JAI_SUID_BIN; run tests/setup-setuid.sh or relink jai.suid manually"
+      fi
+      ;;
+    root)
+      [ "$CURRENT_UID" -eq 0 ] ||
+        skip "JAI_TEST_PRIVILEGE_MODE=root requires uid 0"
+      TEST_PRIVILEGE_MODE=root
+      JAI_BIN=$JAI_BUILD_BIN
+      ;;
+    setuid)
+      if setuid_jai=$(find_setuid_jai); then
+        TEST_PRIVILEGE_MODE=setuid
+        JAI_BIN=$setuid_jai
+      else
+        skip "current setuid jai not found at $JAI_SUID_BIN"
+      fi
+      ;;
+    *)
+      fail "unknown JAI_TEST_PRIVILEGE_MODE: $requested_mode"
+      ;;
+  esac
+}
+
+register_cleanup_path() {
+  CLEANUP_PATHS="${CLEANUP_PATHS}${CLEANUP_PATHS:+ }$1"
+}
+
+cleanup_jai() {
+  if [ -x "$JAI_BIN" ]; then
+    cfgdir=${CONFIG_DIR:-$REAL_HOME/.jai}
+    case $TEST_PRIVILEGE_MODE in
+      root)
+        env SUDO_USER="$REAL_USER" USER="$REAL_USER" LOGNAME="$REAL_USER" \
+          HOME="$REAL_HOME" JAI_CONFIG_DIR="$cfgdir" \
+          "$JAI_BIN" -u >/dev/null 2>&1 || true
+        ;;
+      setuid)
+        env -u SUDO_USER USER="$REAL_USER" LOGNAME="$REAL_USER" \
+          HOME="$REAL_HOME" JAI_CONFIG_DIR="$cfgdir" \
+          "$JAI_BIN" -u >/dev/null 2>&1 || true
+        ;;
+    esac
+  fi
+}
+
+cleanup_test() {
+  cleanup_jai || true
+  for path in $CLEANUP_PATHS; do
+    rm -rf -- "$path" >/dev/null 2>&1 || true
+  done
+  if [ -n "$TEST_ROOT" ] && [ -d "$TEST_ROOT" ]; then
+    rm -rf -- "$TEST_ROOT" >/dev/null 2>&1 || true
+  fi
+}
+
+setup_test() {
+  label=$1
+  configure_privilege_mode
+  ensure_current_build_jai
+  [ -x "$JAI_BIN" ] || fail "$JAI_BIN has not been built"
+  TEST_ROOT=$(mktemp -d "$ABS_TOP_BUILDDIR/test-tmp.$label.XXXXXX")
+  CONFIG_DIR=$(mktemp -d "/dev/shm/jai-config.$label.XXXXXX")
+  WORKDIR=$TEST_ROOT/work
+  CLEANUP_PATHS=$CONFIG_DIR
+  mkdir -p "$WORKDIR"
+  chmod 755 "$CONFIG_DIR"
+  if [ "$TEST_PRIVILEGE_MODE" = "root" ]; then
+    chown "$REAL_USER" "$TEST_ROOT" "$CONFIG_DIR" "$WORKDIR"
+  fi
+  CAPTURE_OUT_FILE=$TEST_ROOT/capture.out
+  CAPTURE_ERR_FILE=$TEST_ROOT/capture.err
+  trap cleanup_test EXIT HUP INT TERM
+  cleanup_jai
+}
+
+require_built_helper() {
+  [ -x "$1" ] || fail "$1 has not been built"
+}
+
+require_setsid() {
+  command -v setsid >/dev/null 2>&1 || skip "setsid not found"
+}
+
+run_root() {
+  case $TEST_PRIVILEGE_MODE in
+    root)
+      "$@"
+      ;;
+    *)
+      fail "run_root is unavailable in $TEST_PRIVILEGE_MODE mode"
+      ;;
+  esac
+}
+
+run_jai_launcher() {
+  case $TEST_PRIVILEGE_MODE in
+    setuid)
+      "$@"
+      ;;
+    root)
+      run_root "$@"
+      ;;
+  esac
+}
+
+run_jai_no_tty() {
+  require_setsid
+  case $TEST_PRIVILEGE_MODE in
+    root)
+      setsid env SUDO_USER="$REAL_USER" USER="$REAL_USER" LOGNAME="$REAL_USER" \
+        HOME="$REAL_HOME" JAI_CONFIG_DIR="$CONFIG_DIR" "$JAI_BIN" "$@"
+      ;;
+    setuid)
+      setsid env -u SUDO_USER USER="$REAL_USER" LOGNAME="$REAL_USER" \
+        HOME="$REAL_HOME" JAI_CONFIG_DIR="$CONFIG_DIR" "$JAI_BIN" "$@"
+      ;;
+  esac
+}
+
+run_jai() {
+  case $TEST_PRIVILEGE_MODE in
+    root)
+      env SUDO_USER="$REAL_USER" USER="$REAL_USER" LOGNAME="$REAL_USER" \
+        HOME="$REAL_HOME" JAI_CONFIG_DIR="$CONFIG_DIR" "$JAI_BIN" "$@"
+      ;;
+    setuid)
+      env -u SUDO_USER USER="$REAL_USER" LOGNAME="$REAL_USER" \
+        HOME="$REAL_HOME" JAI_CONFIG_DIR="$CONFIG_DIR" "$JAI_BIN" "$@"
+      ;;
+  esac
+}
+
+run_jai_with_env() {
+  envpair=$1
+  shift
+  case $TEST_PRIVILEGE_MODE in
+    root)
+      env SUDO_USER="$REAL_USER" USER="$REAL_USER" LOGNAME="$REAL_USER" \
+        HOME="$REAL_HOME" JAI_CONFIG_DIR="$CONFIG_DIR" "$envpair" \
+        "$JAI_BIN" "$@"
+      ;;
+    setuid)
+      env -u SUDO_USER USER="$REAL_USER" LOGNAME="$REAL_USER" \
+        HOME="$REAL_HOME" JAI_CONFIG_DIR="$CONFIG_DIR" "$envpair" \
+        "$JAI_BIN" "$@"
+      ;;
+  esac
+}
+
+init_config() {
+  run_jai --init >/dev/null 2>&1
+  chmod 755 "$CONFIG_DIR"
+}
+
+capture() {
+  if "$@" >"$CAPTURE_OUT_FILE" 2>"$CAPTURE_ERR_FILE"; then
+    CAPTURE_STATUS=0
+  else
+    CAPTURE_STATUS=$?
+  fi
+  CAPTURE_STDOUT=$(cat "$CAPTURE_OUT_FILE")
+  CAPTURE_STDERR=$(cat "$CAPTURE_ERR_FILE")
+}
+
+capture_in_dir() {
+  dir=$1
+  shift
+  if (cd "$dir" && "$@") >"$CAPTURE_OUT_FILE" 2>"$CAPTURE_ERR_FILE"; then
+    CAPTURE_STATUS=0
+  else
+    CAPTURE_STATUS=$?
+  fi
+  CAPTURE_STDOUT=$(cat "$CAPTURE_OUT_FILE")
+  CAPTURE_STDERR=$(cat "$CAPTURE_ERR_FILE")
+}
+
+assert_status() {
+  [ "$CAPTURE_STATUS" -eq "$1" ] ||
+    fail "expected status $1, got $CAPTURE_STATUS"
+}
+
+assert_eq() {
+  [ "$1" = "$2" ] || fail "expected [$2], got [$1]"
+}
+
+assert_contains() {
+  printf '%s\n' "$1" | grep -F -- "$2" >/dev/null ||
+    fail "expected output to contain [$2]"
+}
+
+assert_not_contains() {
+  if printf '%s\n' "$1" | grep -F -- "$2" >/dev/null; then
+    fail "expected output not to contain [$2]"
+  fi
+}
+
+assert_path_exists() {
+  [ -e "$1" ] || fail "expected path to exist: $1"
+}
+
+assert_path_missing() {
+  [ ! -e "$1" ] || fail "expected path to be absent: $1"
+}
+
+assert_file_equals() {
+  actual=$(cat "$1")
+  [ "$actual" = "$2" ] || fail "expected $1 to contain [$2], got [$actual]"
+}
+
+assert_root_file_equals() {
+  capture cat "$1"
+  assert_status 0
+  assert_eq "$CAPTURE_STDOUT" "$2"
+}
+
+assert_output_line() {
+  grep -Fx -- "$1" "$CAPTURE_OUT_FILE" >/dev/null ||
+    fail "expected output line [$1]"
+}
+
+assert_no_output_line() {
+  if grep -Fx -- "$1" "$CAPTURE_OUT_FILE" >/dev/null; then
+    fail "did not expect output line [$1]"
+  fi
+}
+
+assert_root_path_exists() {
+  test -e "$1" || fail "expected root-visible path to exist: $1"
+}
+
+assert_root_path_missing() {
+  if test -e "$1"; then
+    fail "expected root-visible path to be absent: $1"
+  fi
+}
+
+get_mount_line() {
+  awk -v mp="$1" '
+    $5 == mp { print; found = 1 }
+    END { exit found ? 0 : 1 }
+  ' /proc/self/mountinfo
+}
+
+assert_mount_exists() {
+  get_mount_line "$1" >/dev/null 2>&1 ||
+    fail "expected mount to exist at $1"
+}
+
+assert_no_mount() {
+  if get_mount_line "$1" >/dev/null 2>&1; then
+    fail "expected no mount at $1"
+  fi
+}
+
+ensure_untrusted_user() {
+  if ! getent passwd "$UNTRUSTED_USER" >/dev/null 2>&1; then
+      skip "$UNTRUSTED_USER is missing; run systemd-sysusers ./jai.conf"
+  fi
+  entry=$(getent passwd "$UNTRUSTED_USER")
+  uid=$(printf '%s\n' "$entry" | cut -d: -f3)
+  home=$(printf '%s\n' "$entry" | cut -d: -f6)
+  gecos=$(printf '%s\n' "$entry" | cut -d: -f5)
+  [ "$uid" -ne 0 ] || fail "$UNTRUSTED_USER must not have uid 0"
+  [ "$home" = "/" ] || fail "$UNTRUSTED_USER must have home /"
+  [ "$gecos" = "$UNTRUSTED_GECOS" ] ||
+    fail "$UNTRUSTED_USER must have GECOS $UNTRUSTED_GECOS"
+  UNTRUSTED_UID=$uid
+}
tests/config-dir-hidden.sh
new file mode 100755
index 0000000..15026db
--- /dev/null
+++ b/tests/config-dir-hidden.sh
@@ -0,0 +1,19 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test xfail-config-dir-hidden
+
+CONFIG_DIR=$REAL_HOME/jai-test-config-hidden-$$
+register_cleanup_path "$CONFIG_DIR"
+mkdir -p "$CONFIG_DIR"
+
+init_config
+ensure_untrusted_user
+
+capture_in_dir "$REAL_HOME" run_jai -m strict -D /usr/bin/true
+if [ "$CAPTURE_STATUS" -ne 0 ]; then
+  printf '%s\n' 'FAIL: strict mode should not fail just because JAI_CONFIG_DIR is under $HOME and already hidden' >&2
+  printf '%s\n' "$CAPTURE_STDERR" >&2
+  exit 1
+fi
tests/config-precedence.sh
new file mode 100755
index 0000000..73ec90b
--- /dev/null
+++ b/tests/config-precedence.sh
@@ -0,0 +1,45 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test config-precedence
+init_config
+
+cat >"$CONFIG_DIR/shared.conf" <<'EOF'
+setenv FROM_SHARED=shared
+unsetenv KEEP_ME
+EOF
+
+cat >"$CONFIG_DIR/probe.conf" <<'EOF'
+conf .defaults
+conf shared.conf
+mode bare
+jail from-config
+setenv FROM_PROBE=probe
+command /usr/bin/env
+EOF
+
+capture_in_dir "$WORKDIR" run_jai_with_env KEEP_ME=keep -D probe
+assert_status 0
+assert_contains "$CAPTURE_STDOUT" "FROM_SHARED=shared"
+assert_contains "$CAPTURE_STDOUT" "FROM_PROBE=probe"
+assert_contains "$CAPTURE_STDOUT" "JAI_MODE=bare"
+assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=from-config"
+assert_no_output_line "KEEP_ME=keep"
+
+capture_in_dir "$WORKDIR" run_jai_with_env KEEP_ME=keep --setenv KEEP_ME --setenv CLI_OVERRIDE=cli -j cli-name -m casual probe
+assert_status 0
+assert_output_line "KEEP_ME=keep"
+assert_output_line "CLI_OVERRIDE=cli"
+assert_contains "$CAPTURE_STDOUT" "JAI_MODE=casual"
+assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=cli-name"
+
+cat >"$CONFIG_DIR/expand.conf" <<'EOF'
+conf .defaults
+setenv EXPANDED=${SRC_VALUE}
+command /usr/bin/env
+EOF
+
+capture_in_dir "$WORKDIR" run_jai_with_env SRC_VALUE=expanded -C expand
+assert_status 0
+assert_output_line "EXPANDED=expanded"
tests/home-cwd-guard.sh
new file mode 100755
index 0000000..697dbbf
--- /dev/null
+++ b/tests/home-cwd-guard.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test home-cwd-guard
+init_config
+
+capture_in_dir "$REAL_HOME" run_jai /usr/bin/true
+assert_status 1
+assert_contains "$CAPTURE_STDERR" 'Refusing to grant your entire home directory to jailed code.'
+
+capture grep -F 'If you are in your home directory, you can launch jai with' \
+  "$ABS_TOP_SRCDIR/jai.1.md"
+assert_status 0
tests/jai-test-probe.cc
new file mode 100644
index 0000000..562698e
--- /dev/null
+++ b/tests/jai-test-probe.cc
@@ -0,0 +1,190 @@
+#include <csignal>
+#include <cstdlib>
+#include <filesystem>
+#include <iostream>
+#include <set>
+#include <string>
+#include <string_view>
+#include <system_error>
+#include <vector>
+
+#include <dirent.h>
+#include <fcntl.h>
+#include <sys/ioctl.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+namespace {
+
+[[noreturn]] void
+die(std::string_view msg)
+{
+  std::cerr << "FAIL: " << msg << '\n';
+  std::exit(1);
+}
+
+void
+check(bool ok, std::string_view msg)
+{
+  if (!ok)
+    die(msg);
+}
+
+int
+signal_from_name(std::string_view name)
+{
+  if (name == "stop")
+    return SIGSTOP;
+  if (name == "tstp")
+    return SIGTSTP;
+  if (name == "ttin")
+    return SIGTTIN;
+  if (name == "ttou")
+    return SIGTTOU;
+  die("unknown signal name");
+}
+
+std::string
+join_pids(const std::set<int> &pids)
+{
+  std::string out;
+  bool first = true;
+  for (int pid : pids) {
+    if (!first)
+      out += ',';
+    first = false;
+    out += std::to_string(pid);
+  }
+  return out;
+}
+
+std::set<int>
+numeric_proc_entries()
+{
+  std::set<int> out;
+  DIR *dir = opendir("/proc");
+  check(dir != nullptr, "opendir(/proc) failed");
+  while (dirent *de = readdir(dir)) {
+    char *end{};
+    long v = std::strtol(de->d_name, &end, 10);
+    if (*de->d_name != '\0' && end && *end == '\0')
+      out.insert(static_cast<int>(v));
+  }
+  closedir(dir);
+  return out;
+}
+
+int
+open_tty()
+{
+  return open("/dev/tty", O_RDWR | O_CLOEXEC);
+}
+
+long
+foreground_pgrp(int ttyfd)
+{
+  if (ttyfd < 0)
+    return -1;
+  auto pg = tcgetpgrp(ttyfd);
+  if (pg == -1)
+    return -1;
+  return pg;
+}
+
+void
+print_probe()
+{
+  struct stat tmp{}, vartmp{};
+  check(stat("/tmp", &tmp) == 0, "stat(/tmp) failed");
+  check(stat("/var/tmp", &vartmp) == 0, "stat(/var/tmp) failed");
+
+  int ttyfd = open_tty();
+  const auto pids = numeric_proc_entries();
+
+  std::cout << "pid=" << getpid() << '\n';
+  std::cout << "ppid=" << getppid() << '\n';
+  std::cout << "pgid=" << getpgid(0) << '\n';
+  std::cout << "sid=" << getsid(0) << '\n';
+  std::cout << "tty=" << (ttyfd >= 0 ? "yes" : "no") << '\n';
+  std::cout << "fg_pgrp=" << foreground_pgrp(ttyfd) << '\n';
+  std::cout << "tmp_same_inode="
+            << ((tmp.st_dev == vartmp.st_dev && tmp.st_ino == vartmp.st_ino)
+                    ? "yes"
+                    : "no")
+            << '\n';
+  std::cout << "proc_pids=" << join_pids(pids) << '\n';
+
+  if (ttyfd >= 0)
+    close(ttyfd);
+}
+
+void
+run_suspend(int argc, char **argv)
+{
+  bool new_pgrp = false;
+  bool foreground = false;
+  int stop_signal = SIGSTOP;
+
+  for (int i = 2; i < argc; ++i) {
+    std::string_view arg = argv[i];
+    if (arg == "--new-pgrp")
+      new_pgrp = true;
+    else if (arg == "--foreground")
+      foreground = true;
+    else if (arg.starts_with("--signal="))
+      stop_signal = signal_from_name(arg.substr(9));
+    else
+      die("unknown suspend argument");
+  }
+
+  if (new_pgrp)
+    check(setpgid(0, 0) == 0, "setpgid failed");
+
+  int ttyfd = open_tty();
+  if (foreground && ttyfd >= 0) {
+    struct sigaction old_act{}, ign{};
+    ign.sa_handler = SIG_IGN;
+    check(sigaction(SIGTTOU, &ign, &old_act) == 0, "sigaction(SIGTTOU) failed");
+    check(tcsetpgrp(ttyfd, getpgrp()) == 0, "tcsetpgrp failed");
+    check(sigaction(SIGTTOU, &old_act, nullptr) == 0,
+          "restoring SIGTTOU failed");
+  }
+
+  std::cout << "READY"
+            << " pid=" << getpid() << " ppid=" << getppid()
+            << " pgid=" << getpgid(0) << " sid=" << getsid(0)
+            << " fg_pgrp=" << foreground_pgrp(ttyfd)
+            << " signal=" << stop_signal << '\n'
+            << std::flush;
+
+  check(raise(stop_signal) == 0, "raise failed");
+
+  std::cout << "RESUMED"
+            << " pid=" << getpid() << " pgid=" << getpgid(0)
+            << " fg_pgrp=" << foreground_pgrp(ttyfd) << '\n'
+            << std::flush;
+
+  if (ttyfd >= 0)
+    close(ttyfd);
+}
+
+} // namespace
+
+int
+main(int argc, char **argv)
+{
+  std::ios::sync_with_stdio(false);
+
+  if (argc < 2)
+    die("missing subcommand");
+
+  std::string_view cmd = argv[1];
+  if (cmd == "probe")
+    print_probe();
+  else if (cmd == "suspend")
+    run_suspend(argc, argv);
+  else
+    die("unknown subcommand");
+
+  return 0;
+}
tests/jai-test-pty-driver.cc
new file mode 100644
index 0000000..8e5067d
--- /dev/null
+++ b/tests/jai-test-pty-driver.cc
@@ -0,0 +1,462 @@
+#include <chrono>
+#include <csignal>
+#include <cstdlib>
+#include <cstring>
+#include <iostream>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <thread>
+#include <vector>
+
+#include <fcntl.h>
+#include <poll.h>
+#include <pty.h>
+#include <signal.h>
+#include <sys/ioctl.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+namespace {
+
+using clock_type = std::chrono::steady_clock;
+
+struct Config {
+  enum class ParentMode { orphan, shell };
+  enum class StopExpectation { exact, any };
+
+  std::string jai_bin;
+  std::string helper_bin;
+  std::string config_dir;
+  std::string workdir;
+  std::string user;
+  std::string signal_name = "stop";
+  int signal_number = SIGSTOP;
+  bool new_pgrp = false;
+  bool foreground = false;
+  ParentMode parent_mode = ParentMode::orphan;
+  StopExpectation stop_expectation = StopExpectation::exact;
+};
+
+[[noreturn]] void
+die(std::string_view msg)
+{
+  std::cerr << "FAIL: " << msg << '\n';
+  std::exit(1);
+}
+
+void
+check(bool ok, std::string_view msg)
+{
+  if (!ok)
+    die(msg);
+}
+
+int
+parse_signal(std::string_view name)
+{
+  if (name == "stop")
+    return SIGSTOP;
+  if (name == "tstp")
+    return SIGTSTP;
+  if (name == "ttin")
+    return SIGTTIN;
+  if (name == "ttou")
+    return SIGTTOU;
+  die("unknown signal");
+}
+
+Config
+parse_args(int argc, char **argv)
+{
+  Config cfg;
+  for (int i = 1; i < argc; ++i) {
+    std::string_view arg = argv[i];
+    auto need_value = [&](std::string_view name) -> std::string {
+      if (i + 1 >= argc)
+        die(std::string(name) + " requires a value");
+      return argv[++i];
+    };
+
+    if (arg == "--jai-bin")
+      cfg.jai_bin = need_value("--jai-bin");
+    else if (arg == "--helper-bin")
+      cfg.helper_bin = need_value("--helper-bin");
+    else if (arg == "--config-dir")
+      cfg.config_dir = need_value("--config-dir");
+    else if (arg == "--workdir")
+      cfg.workdir = need_value("--workdir");
+    else if (arg == "--user")
+      cfg.user = need_value("--user");
+    else if (arg == "--new-pgrp")
+      cfg.new_pgrp = true;
+    else if (arg == "--foreground")
+      cfg.foreground = true;
+    else if (arg == "--shell-parent")
+      cfg.parent_mode = Config::ParentMode::shell;
+    else if (arg == "--accept-any-stop-signal")
+      cfg.stop_expectation = Config::StopExpectation::any;
+    else if (arg.starts_with("--signal="))
+      cfg.signal_name = std::string(arg.substr(9));
+    else
+      die(std::string("unknown argument: ") + std::string(arg));
+  }
+
+  cfg.signal_number = parse_signal(cfg.signal_name);
+  check(!cfg.jai_bin.empty(), "--jai-bin is required");
+  check(!cfg.helper_bin.empty(), "--helper-bin is required");
+  check(!cfg.config_dir.empty(), "--config-dir is required");
+  check(!cfg.workdir.empty(), "--workdir is required");
+  check(!cfg.user.empty(), "--user is required");
+  return cfg;
+}
+
+std::string
+read_until(int fd, std::string_view needle, std::chrono::milliseconds timeout)
+{
+  auto deadline = clock_type::now() + timeout;
+  std::string out;
+  char buf[4096];
+
+  for (;;) {
+    if (out.find(needle) != std::string::npos)
+      return out;
+
+    auto now = clock_type::now();
+    if (now >= deadline)
+      break;
+
+    auto wait_ms =
+        std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now)
+            .count();
+    pollfd pfd{.fd = fd, .events = POLLIN | POLLHUP, .revents = 0};
+    int pr = poll(&pfd, 1, static_cast<int>(wait_ms));
+    if (pr < 0) {
+      if (errno == EINTR)
+        continue;
+      die(std::string("poll failed: ") + std::strerror(errno));
+    }
+    if (pr == 0)
+      continue;
+
+    ssize_t n = read(fd, buf, sizeof(buf));
+    if (n > 0) {
+      out.append(buf, static_cast<std::size_t>(n));
+      continue;
+    }
+    if (n == 0 || errno == EIO)
+      break;
+    if (errno == EINTR)
+      continue;
+    die(std::string("read failed: ") + std::strerror(errno));
+  }
+
+  die(std::string("timeout waiting for \"") + std::string(needle) +
+      "\"; output was:\n" + out);
+}
+
+int
+wait_for_stop(pid_t pid, std::chrono::milliseconds timeout)
+{
+  auto deadline = clock_type::now() + timeout;
+  for (;;) {
+    int status = 0;
+    pid_t r = waitpid(pid, &status, WUNTRACED | WCONTINUED | WNOHANG);
+    if (r == -1) {
+      if (errno == EINTR)
+        continue;
+      die(std::string("waitpid failed: ") + std::strerror(errno));
+    }
+    if (r == pid) {
+      if (WIFSTOPPED(status))
+        return WSTOPSIG(status);
+      if (WIFEXITED(status))
+        die(std::string("jai exited early with status ") +
+            std::to_string(WEXITSTATUS(status)));
+      if (WIFSIGNALED(status))
+        die(std::string("jai died from signal ") +
+            std::to_string(WTERMSIG(status)));
+    }
+
+    if (clock_type::now() >= deadline)
+      break;
+    std::this_thread::sleep_for(std::chrono::milliseconds(10));
+  }
+  die("jai never stopped after the child self-suspended");
+}
+
+void
+wait_for_exit(pid_t pid, std::chrono::milliseconds timeout)
+{
+  auto deadline = clock_type::now() + timeout;
+  for (;;) {
+    int status = 0;
+    pid_t r = waitpid(pid, &status, WUNTRACED | WCONTINUED | WNOHANG);
+    if (r == -1) {
+      if (errno == EINTR)
+        continue;
+      die(std::string("waitpid failed: ") + std::strerror(errno));
+    }
+    if (r == pid) {
+      if (WIFEXITED(status)) {
+        if (WEXITSTATUS(status) != 0)
+          die(std::string("jai exited with status ") +
+              std::to_string(WEXITSTATUS(status)));
+        return;
+      }
+      if (WIFSIGNALED(status))
+        die(std::string("jai died from signal ") +
+            std::to_string(WTERMSIG(status)));
+    }
+
+    if (clock_type::now() >= deadline)
+      break;
+    std::this_thread::sleep_for(std::chrono::milliseconds(10));
+  }
+  die("timed out waiting for jai to exit");
+}
+
+void
+kill_group(pid_t pgid)
+{
+  if (pgid > 0)
+    kill(-pgid, SIGKILL);
+}
+
+void
+tcsetpgrp_ignore_ttou(int tty_fd, pid_t pgid)
+{
+  struct sigaction old_act{}, ign{};
+  ign.sa_handler = SIG_IGN;
+  check(sigaction(SIGTTOU, &ign, &old_act) == 0, "sigaction(SIGTTOU) failed");
+  check(tcsetpgrp(tty_fd, pgid) == 0, "tcsetpgrp failed");
+  check(sigaction(SIGTTOU, &old_act, nullptr) == 0,
+        "restoring SIGTTOU failed");
+}
+
+[[noreturn]] void
+child_exec_orphan(const Config &cfg, int master_fd, int slave_fd)
+{
+  close(master_fd);
+  check(setsid() != -1, "setsid failed");
+  check(ioctl(slave_fd, TIOCSCTTY, 0) != -1, "TIOCSCTTY failed");
+  check(tcsetpgrp(slave_fd, getpid()) != -1, "tcsetpgrp failed");
+
+  check(dup2(slave_fd, STDIN_FILENO) != -1, "dup2(stdin) failed");
+  check(dup2(slave_fd, STDOUT_FILENO) != -1, "dup2(stdout) failed");
+  check(dup2(slave_fd, STDERR_FILENO) != -1, "dup2(stderr) failed");
+  if (slave_fd > STDERR_FILENO)
+    close(slave_fd);
+
+  check(chdir(cfg.workdir.c_str()) == 0, "chdir failed");
+
+  setenv("SUDO_USER", cfg.user.c_str(), 1);
+  setenv("USER", cfg.user.c_str(), 1);
+  setenv("LOGNAME", cfg.user.c_str(), 1);
+  setenv("JAI_CONFIG_DIR", cfg.config_dir.c_str(), 1);
+  setenv("TERM", "dumb", 1);
+
+  std::vector<std::string> argv_store;
+  auto push = [&](std::string s) { argv_store.push_back(std::move(s)); };
+
+  push(cfg.jai_bin);
+  push(cfg.helper_bin);
+  push("suspend");
+  if (cfg.new_pgrp)
+    push("--new-pgrp");
+  if (cfg.foreground)
+    push("--foreground");
+  push(std::string("--signal=") + cfg.signal_name);
+
+  std::vector<char *> args;
+  args.reserve(argv_store.size() + 1);
+  for (auto &arg : argv_store)
+    args.push_back(arg.data());
+  args.push_back(nullptr);
+
+  execv(cfg.jai_bin.c_str(), args.data());
+  std::cerr << "FAIL: execv(" << cfg.jai_bin << ") failed: "
+            << std::strerror(errno) << '\n';
+  std::exit(1);
+}
+
+[[noreturn]] void
+child_exec_shell(const Config &cfg, int master_fd, int slave_fd)
+{
+  close(master_fd);
+  check(setpgid(0, 0) == 0, "setpgid failed");
+
+  check(dup2(slave_fd, STDIN_FILENO) != -1, "dup2(stdin) failed");
+  check(dup2(slave_fd, STDOUT_FILENO) != -1, "dup2(stdout) failed");
+  check(dup2(slave_fd, STDERR_FILENO) != -1, "dup2(stderr) failed");
+  if (slave_fd > STDERR_FILENO)
+    close(slave_fd);
+
+  check(chdir(cfg.workdir.c_str()) == 0, "chdir failed");
+
+  setenv("SUDO_USER", cfg.user.c_str(), 1);
+  setenv("USER", cfg.user.c_str(), 1);
+  setenv("LOGNAME", cfg.user.c_str(), 1);
+  setenv("JAI_CONFIG_DIR", cfg.config_dir.c_str(), 1);
+  setenv("TERM", "dumb", 1);
+
+  std::vector<std::string> argv_store;
+  auto push = [&](std::string s) { argv_store.push_back(std::move(s)); };
+
+  push(cfg.jai_bin);
+  push(cfg.helper_bin);
+  push("suspend");
+  if (cfg.new_pgrp)
+    push("--new-pgrp");
+  if (cfg.foreground)
+    push("--foreground");
+  push(std::string("--signal=") + cfg.signal_name);
+
+  std::vector<char *> args;
+  args.reserve(argv_store.size() + 1);
+  for (auto &arg : argv_store)
+    args.push_back(arg.data());
+  args.push_back(nullptr);
+
+  execv(cfg.jai_bin.c_str(), args.data());
+  std::cerr << "FAIL: execv(" << cfg.jai_bin << ") failed: "
+            << std::strerror(errno) << '\n';
+  std::exit(1);
+}
+
+[[noreturn]] void
+shell_parent_exec(const Config &cfg, int master_fd, int slave_fd)
+{
+  check(setsid() != -1, "setsid failed");
+  check(ioctl(slave_fd, TIOCSCTTY, 0) != -1, "TIOCSCTTY failed");
+  struct sigaction ign{};
+  ign.sa_handler = SIG_IGN;
+  check(sigaction(SIGHUP, &ign, nullptr) == 0, "sigaction(SIGHUP) failed");
+  pid_t shell_pgid = getpgrp();
+
+  pid_t pid = fork();
+  check(pid != -1, "fork failed");
+  if (pid == 0)
+    child_exec_shell(cfg, master_fd, slave_fd);
+
+  if (setpgid(pid, pid) == -1 && errno != EACCES)
+    die(std::string("setpgid(child) failed: ") + std::strerror(errno));
+  tcsetpgrp_ignore_ttou(slave_fd, pid);
+
+  struct Cleanup {
+    pid_t pid;
+    int master_fd;
+    int slave_fd;
+    pid_t shell_pgid;
+    ~Cleanup()
+    {
+      if (shell_pgid > 0)
+        tcsetpgrp_ignore_ttou(slave_fd, shell_pgid);
+      kill_group(pid);
+      int status = 0;
+      while (waitpid(pid, &status, WNOHANG) > 0)
+        ;
+      if (master_fd >= 0)
+        close(master_fd);
+      if (slave_fd >= 0)
+        close(slave_fd);
+    }
+  } cleanup{pid, master_fd, slave_fd, shell_pgid};
+
+  std::string output =
+      read_until(master_fd, "READY", std::chrono::milliseconds(4000));
+  int stop_sig = wait_for_stop(pid, std::chrono::milliseconds(4000));
+  if (cfg.stop_expectation == Config::StopExpectation::exact &&
+      stop_sig != cfg.signal_number)
+    die(std::string("jai stopped with signal ") + std::to_string(stop_sig) +
+        ", expected " + std::to_string(cfg.signal_number));
+
+  tcsetpgrp_ignore_ttou(slave_fd, shell_pgid);
+  tcsetpgrp_ignore_ttou(slave_fd, pid);
+  check(kill(-pid, SIGCONT) == 0, "kill(SIGCONT) failed");
+  output += read_until(master_fd, "RESUMED", std::chrono::milliseconds(4000));
+  wait_for_exit(pid, std::chrono::milliseconds(4000));
+
+  if (output.find("READY") == std::string::npos ||
+      output.find("RESUMED") == std::string::npos)
+    die("missing helper output");
+
+  tcsetpgrp_ignore_ttou(slave_fd, shell_pgid);
+  cleanup.pid = -1;
+  close(master_fd);
+  cleanup.master_fd = -1;
+  close(slave_fd);
+  cleanup.slave_fd = -1;
+  std::exit(0);
+}
+
+} // namespace
+
+int
+main(int argc, char **argv)
+{
+  Config cfg = parse_args(argc, argv);
+
+  int master_fd = -1;
+  int slave_fd = -1;
+  check(openpty(&master_fd, &slave_fd, nullptr, nullptr, nullptr) == 0,
+        "openpty failed");
+
+  if (cfg.parent_mode == Config::ParentMode::shell) {
+    pid_t shell_pid = fork();
+    check(shell_pid != -1, "fork failed");
+    if (shell_pid == 0)
+      shell_parent_exec(cfg, master_fd, slave_fd);
+    close(master_fd);
+    close(slave_fd);
+    int status = 0;
+    check(waitpid(shell_pid, &status, 0) == shell_pid, "waitpid failed");
+    if (WIFEXITED(status))
+      return WEXITSTATUS(status);
+    if (WIFSIGNALED(status))
+      die(std::string("shell parent died from signal ") +
+          std::to_string(WTERMSIG(status)));
+    die("shell parent ended unexpectedly");
+  }
+
+  pid_t pid = fork();
+  check(pid != -1, "fork failed");
+  if (pid == 0)
+    child_exec_orphan(cfg, master_fd, slave_fd);
+  close(slave_fd);
+
+  struct Cleanup {
+    pid_t pid;
+    int master_fd;
+    ~Cleanup()
+    {
+      kill_group(pid);
+      int status = 0;
+      while (waitpid(pid, &status, WNOHANG) > 0)
+        ;
+      if (master_fd >= 0)
+        close(master_fd);
+    }
+  } cleanup{pid, master_fd};
+
+  std::string output =
+      read_until(master_fd, "READY", std::chrono::milliseconds(4000));
+  int stop_sig = wait_for_stop(pid, std::chrono::milliseconds(4000));
+  if (cfg.stop_expectation == Config::StopExpectation::exact &&
+      stop_sig != cfg.signal_number)
+    die(std::string("jai stopped with signal ") + std::to_string(stop_sig) +
+        ", expected " + std::to_string(cfg.signal_number));
+
+  check(kill(-pid, SIGCONT) == 0, "kill(SIGCONT) failed");
+  output += read_until(master_fd, "RESUMED", std::chrono::milliseconds(4000));
+  wait_for_exit(pid, std::chrono::milliseconds(4000));
+
+  if (output.find("READY") == std::string::npos ||
+      output.find("RESUMED") == std::string::npos)
+    die("missing helper output");
+
+  cleanup.pid = -1;
+  close(master_fd);
+  cleanup.master_fd = -1;
+  return 0;
+}
tests/jail-files.sh
new file mode 100755
index 0000000..e478f47
--- /dev/null
+++ b/tests/jail-files.sh
@@ -0,0 +1,58 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test jail-files
+init_config
+ensure_untrusted_user
+
+cat >"$CONFIG_DIR/jail-include.conf" <<'EOF'
+setenv FROM_JAIL_INCLUDE=include
+EOF
+
+cat >"$CONFIG_DIR/probe.conf" <<'EOF'
+conf .defaults
+mode strict
+jail from-conf
+command /usr/bin/env
+EOF
+
+cat >"$CONFIG_DIR/from-conf.jail" <<'EOF'
+conf jail-include.conf
+mode bare
+setenv FROM_DOTJAIL=yes
+EOF
+
+capture_in_dir "$WORKDIR" run_jai -D -C probe
+assert_status 0
+assert_output_line "FROM_DOTJAIL=yes"
+assert_output_line "FROM_JAIL_INCLUDE=include"
+assert_contains "$CAPTURE_STDOUT" "JAI_MODE=bare"
+assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=from-conf"
+
+capture_in_dir "$WORKDIR" run_jai -C probe -m strict
+assert_status 0
+assert_contains "$CAPTURE_STDOUT" "JAI_MODE=strict"
+assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=from-conf"
+
+capture_in_dir "$WORKDIR" run_jai --jail created /usr/bin/env
+assert_status 0
+assert_contains "$CAPTURE_STDOUT" "JAI_MODE=strict"
+assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=created"
+assert_path_exists "$CONFIG_DIR/created.jail"
+grep -Fx 'mode strict' "$CONFIG_DIR/created.jail" >/dev/null ||
+  fail "created.jail should record strict mode by default"
+
+cat >"$CONFIG_DIR/bad.conf" <<'EOF'
+conf .defaults
+jail bad
+command /usr/bin/true
+EOF
+
+cat >"$CONFIG_DIR/bad.jail" <<'EOF'
+jail nope
+EOF
+
+capture_in_dir "$WORKDIR" run_jai -C bad
+assert_status 1
+assert_contains "$CAPTURE_STDERR" "cannot set name from a .jail file or include"
tests/job-control-same-pgrp-stop.sh
new file mode 100755
index 0000000..ac2e3e5
--- /dev/null
+++ b/tests/job-control-same-pgrp-stop.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test job-control-same-pgrp-stop
+require_built_helper "$JAI_TEST_PROBE"
+require_built_helper "$JAI_TEST_PTY_DRIVER"
+init_config
+
+capture run_jai_launcher "$JAI_TEST_PTY_DRIVER" \
+  --jai-bin "$JAI_BIN" \
+  --helper-bin "$JAI_TEST_PROBE" \
+  --config-dir "$CONFIG_DIR" \
+  --workdir "$WORKDIR" \
+  --user "$REAL_USER" \
+  --signal=stop
+if [ "$CAPTURE_STATUS" -ne 0 ]; then
+  printf '%s\n' "$CAPTURE_STDERR" >&2
+  fail 'same-process-group SIGSTOP job-control path failed'
+fi
tests/job-control-sigtstp-fallback.sh
new file mode 100755
index 0000000..eaf5850
--- /dev/null
+++ b/tests/job-control-sigtstp-fallback.sh
@@ -0,0 +1,23 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test job-control-sigtstp-fallback
+require_built_helper "$JAI_TEST_PROBE"
+require_built_helper "$JAI_TEST_PTY_DRIVER"
+init_config
+
+capture run_jai_launcher "$JAI_TEST_PTY_DRIVER" \
+  --jai-bin "$JAI_BIN" \
+  --helper-bin "$JAI_TEST_PROBE" \
+  --config-dir "$CONFIG_DIR" \
+  --workdir "$WORKDIR" \
+  --user "$REAL_USER" \
+  --accept-any-stop-signal \
+  --new-pgrp \
+  --foreground \
+  --signal=tstp
+if [ "$CAPTURE_STATUS" -ne 0 ]; then
+  printf '%s\n' "$CAPTURE_STDERR" >&2
+  fail 'orphaned job-control topology should still stop and resume jai'
+fi
tests/job-control-sigtstp-preserve.sh
new file mode 100755
index 0000000..df46d32
--- /dev/null
+++ b/tests/job-control-sigtstp-preserve.sh
@@ -0,0 +1,23 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test job-control-sigtstp-preserve
+require_built_helper "$JAI_TEST_PROBE"
+require_built_helper "$JAI_TEST_PTY_DRIVER"
+init_config
+
+capture run_jai_launcher "$JAI_TEST_PTY_DRIVER" \
+  --jai-bin "$JAI_BIN" \
+  --helper-bin "$JAI_TEST_PROBE" \
+  --config-dir "$CONFIG_DIR" \
+  --workdir "$WORKDIR" \
+  --user "$REAL_USER" \
+  --shell-parent \
+  --new-pgrp \
+  --foreground \
+  --signal=tstp
+if [ "$CAPTURE_STATUS" -ne 0 ]; then
+  printf '%s\n' "$CAPTURE_STDERR" >&2
+  fail 'shell job-control topology should preserve SIGTSTP exactly'
+fi
tests/job-control.sh
new file mode 100755
index 0000000..9a4c25d
--- /dev/null
+++ b/tests/job-control.sh
@@ -0,0 +1,22 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test job-control
+require_built_helper "$JAI_TEST_PROBE"
+require_built_helper "$JAI_TEST_PTY_DRIVER"
+init_config
+
+capture run_jai_launcher "$JAI_TEST_PTY_DRIVER" \
+  --jai-bin "$JAI_BIN" \
+  --helper-bin "$JAI_TEST_PROBE" \
+  --config-dir "$CONFIG_DIR" \
+  --workdir "$WORKDIR" \
+  --user "$REAL_USER" \
+  --new-pgrp \
+  --foreground \
+  --signal=stop
+if [ "$CAPTURE_STATUS" -ne 0 ]; then
+  printf '%s\n' "$CAPTURE_STDERR" >&2
+  fail 'own-process-group SIGSTOP job-control path failed'
+fi
tests/mask.sh
new file mode 100755
index 0000000..e8d486c
--- /dev/null
+++ b/tests/mask.sh
@@ -0,0 +1,39 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test mask
+init_config
+
+TARGET_NAME=jai-mask-target-$$
+TARGET_PATH=$REAL_HOME/$TARGET_NAME
+
+register_cleanup_path "$TARGET_PATH"
+
+printf 'mask-me' >"$TARGET_PATH"
+
+cat >"$CONFIG_DIR/mask-on.conf" <<EOF
+conf .defaults
+mode casual
+jail masked
+mask $TARGET_NAME
+EOF
+
+cleanup_jai
+capture_in_dir "$WORKDIR" run_jai -C mask-on /bin/sh -c '[ -e "$1" ] && printf visible || printf hidden' sh "$TARGET_PATH"
+assert_status 0
+assert_eq "$CAPTURE_STDOUT" "hidden"
+
+cat >"$CONFIG_DIR/mask-off.conf" <<EOF
+conf .defaults
+mode casual
+jail unmasked
+mask $TARGET_NAME
+unmask $TARGET_NAME
+EOF
+
+cleanup_jai
+capture_in_dir "$WORKDIR" run_jai -C mask-off /bin/sh -c '[ -e "$1" ] && printf visible || printf hidden' sh "$TARGET_PATH"
+assert_status 0
+assert_eq "$CAPTURE_STDOUT" "visible"
+assert_file_equals "$TARGET_PATH" "mask-me"
tests/modes.sh
new file mode 100755
index 0000000..070f2f1
--- /dev/null
+++ b/tests/modes.sh
@@ -0,0 +1,35 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test modes
+init_config
+ensure_untrusted_user
+
+HOST_HOME_FILE=$REAL_HOME/jai-strict-hidden-$$
+STRICT_GRANTED=$WORKDIR/strict-granted.txt
+
+register_cleanup_path "$HOST_HOME_FILE"
+
+printf 'secret' >"$HOST_HOME_FILE"
+
+capture_in_dir "$WORKDIR" run_jai -j named /usr/bin/env
+assert_status 0
+assert_contains "$CAPTURE_STDOUT" "JAI_MODE=strict"
+assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=named"
+
+capture_in_dir "$WORKDIR" run_jai -m strict /bin/sh -c 'id -u'
+assert_status 0
+assert_eq "$CAPTURE_STDOUT" "$UNTRUSTED_UID"
+
+capture run_jai -D -m bare -j barecase /bin/sh -c 'id -u'
+assert_status 0
+assert_eq "$CAPTURE_STDOUT" "$REAL_UID"
+
+capture_in_dir "$WORKDIR" run_jai -m strict /bin/sh -c '[ -e "$1" ] && printf visible || printf hidden' sh "$HOST_HOME_FILE"
+assert_status 0
+assert_eq "$CAPTURE_STDOUT" "hidden"
+
+capture_in_dir "$WORKDIR" run_jai -m strict /bin/sh -c 'printf strict > "$1"' sh "$STRICT_GRANTED"
+assert_status 0
+assert_file_equals "$STRICT_GRANTED" "strict"
tests/options_test.cc
new file mode 100644
index 0000000..9e26172
--- /dev/null
+++ b/tests/options_test.cc
@@ -0,0 +1,204 @@
+#include "../options.h"
+
+#include <cassert>
+#include <filesystem>
+#include <functional>
+#include <initializer_list>
+#include <string>
+#include <string_view>
+#include <vector>
+
+std::filesystem::path prog;
+
+namespace {
+
+struct Argv {
+  std::vector<std::string> args;
+  std::vector<char *> ptrs;
+
+  Argv(std::initializer_list<std::string_view> init)
+  {
+    args.reserve(init.size());
+    ptrs.reserve(init.size());
+    for (auto arg : init)
+      args.emplace_back(arg);
+    for (auto &arg : args)
+      ptrs.push_back(arg.data());
+  }
+};
+
+void
+expect_error(std::string_view msg, const std::function<void()> &f)
+{
+  try {
+    f();
+    assert(false);
+  } catch (const Options::Error &e) {
+    assert(std::string_view(e.what()) == msg);
+  }
+}
+
+void
+test_parse_argv()
+{
+  bool a = false;
+  bool b = false;
+  std::string dir;
+  std::string opt = "unset";
+
+  Options o;
+  o("-a", [&] { a = true; });
+  o("-b", [&] { b = true; });
+  o("-d", "--dir", [&](std::string arg) { dir = arg; });
+  o("-o", "--opt", [&](std::string arg = "default") { opt = arg; });
+
+  Argv argv{"prog", "-ab", "-dpath", "-o", "file"};
+  auto rest = o.parse_argv(argv.ptrs.size(), argv.ptrs.data());
+
+  assert(a);
+  assert(b);
+  assert(dir == "path");
+  assert(opt == "default");
+  assert(rest.size() == 1);
+  assert(std::string_view(rest.front()) == "file");
+}
+
+void
+test_parse_stops()
+{
+  bool seen = false;
+
+  Options o;
+  o("-a", [&] { seen = true; });
+
+  {
+    Argv argv{"prog", "-a", "file", "--ignored"};
+    auto rest = o.parse_argv(argv.ptrs.size(), argv.ptrs.data());
+    assert(seen);
+    assert(rest.size() == 2);
+    assert(std::string_view(rest[0]) == "file");
+  }
+
+  {
+    Argv argv{"prog", "--", "-a"};
+    auto rest = o.parse_argv(argv.ptrs.size(), argv.ptrs.data());
+    assert(rest.size() == 1);
+    assert(std::string_view(rest[0]) == "-a");
+  }
+}
+
+void
+test_parse_errors()
+{
+  Options o;
+  o("-a", [] {});
+  o("--all", [] {});
+  o("-d", "--dir", [](std::string) {});
+
+  expect_error("unknown option --bad", [&] {
+    Argv argv{"prog", "--bad"};
+    o.parse_argv(argv.ptrs.size(), argv.ptrs.data());
+  });
+  expect_error("option -d requires an argument", [&] {
+    Argv argv{"prog", "-d"};
+    o.parse_argv(argv.ptrs.size(), argv.ptrs.data());
+  });
+  expect_error("option --all takes no argument", [&] {
+    Argv argv{"prog", "--all=x"};
+    o.parse_argspan(std::span{argv.args}.subspan(1));
+  });
+
+  bool seen = false;
+  Options p;
+  p("-a", [&] { seen = true; });
+  expect_error("unknown option -b", [&] {
+    Argv argv{"prog", "-ab"};
+    p.parse_argv(argv.ptrs.size(), argv.ptrs.data());
+  });
+  assert(seen);
+}
+
+void
+test_complete_args()
+{
+  using C = Options::Completions;
+
+  Options o;
+  o("-a", [] {});
+  o("-b", "--beta", [] {});
+  o("-d", "--dir", [](std::string) {});
+  o("-o", "--opt", [](std::string arg = {}) { (void)arg; });
+
+  {
+    Argv argv{"prog", "-"};
+    auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data());
+    assert(c.kind == C::kRawCompletions);
+    assert((c.vals == std::vector<std::string>{
+               "--beta ", "--dir ", "--opt=", "-a ", "-b ", "-d ", "-o"}));
+  }
+  {
+    Argv argv{"prog", "--"};
+    auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data());
+    assert(c.kind == C::kRawCompletions);
+    assert((c.vals ==
+            std::vector<std::string>{"--beta ", "--dir ", "--opt="}));
+  }
+  {
+    Argv argv{"prog", "-a"};
+    auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data());
+    assert(c.kind == C::kRawCompletions);
+    assert((c.vals == std::vector<std::string>{"-a "}));
+  }
+  {
+    Argv argv{"prog", "-d"};
+    auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data());
+    assert(c.kind == C::kRawCompletions);
+    assert((c.vals == std::vector<std::string>{"-d "}));
+  }
+  {
+    Argv argv{"prog", "-o"};
+    auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data());
+    assert(c.kind == C::kArgCompletions);
+    assert((c.vals == std::vector<std::string>{"-o", "", "-o"}));
+  }
+  {
+    Argv argv{"prog", "--dir", "fo"};
+    auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data());
+    assert(c.kind == C::kArgCompletions);
+    assert((c.vals == std::vector<std::string>{"--dir", "fo", ""}));
+  }
+  {
+    Argv argv{"prog", "--dir=fo"};
+    auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data());
+    assert(c.kind == C::kArgCompletions);
+    assert((c.vals == std::vector<std::string>{"--dir", "fo", "--dir="}));
+  }
+  {
+    Argv argv{"prog", "-adfo"};
+    auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data());
+    assert(c.kind == C::kArgCompletions);
+    assert((c.vals == std::vector<std::string>{"-d", "fo", "-ad"}));
+  }
+  {
+    Argv argv{"prog", "--opt", "file"};
+    auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data());
+    assert(c.kind == 2);
+    assert(c.vals.empty());
+  }
+  {
+    Argv argv{"prog", "--bad", "-"};
+    auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data());
+    assert(c.kind == C::kNoCompletions);
+  }
+}
+
+} // namespace
+
+int
+main()
+{
+  test_parse_argv();
+  test_parse_stops();
+  test_parse_errors();
+  test_complete_args();
+}
tests/process-state.sh
new file mode 100755
index 0000000..0bfa4a9
--- /dev/null
+++ b/tests/process-state.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test process-state
+require_built_helper "$JAI_TEST_PROBE"
+init_config
+
+capture_in_dir "$WORKDIR" run_jai_no_tty "$JAI_TEST_PROBE" probe
+assert_status 0
+assert_output_line "pid=2"
+assert_output_line "ppid=1"
+assert_output_line "tty=no"
+assert_output_line "fg_pgrp=-1"
+assert_output_line "tmp_same_inode=yes"
+assert_output_line "proc_pids=1,2"
tests/setup-setuid.sh.in
new file mode 100644
index 0000000..d691636
--- /dev/null
+++ b/tests/setup-setuid.sh.in
@@ -0,0 +1,134 @@
+#!/bin/sh
+
+set -eu
+
+abs_top_builddir='@TEST_ABS_TOP_BUILDDIR@'
+link_path=$abs_top_builddir/jai.suid
+
+usage() {
+  cat <<EOF
+usage: $0 [--user USER]
+
+Installs a setuid-root copy of jai for running the test suite without sudo.
+The binary is placed in a private per-user directory under /var/tmp and
+$link_path is updated to point to it.
+
+After setup, just run:
+
+  make check
+EOF
+}
+
+fail() {
+  printf 'FAIL: %s\n' "$*" >&2
+  exit 1
+}
+
+default_test_user() {
+  owner_uid=$(stat -c %u "$abs_top_builddir") || return 1
+  if [ "$owner_uid" -eq 0 ]; then
+    owner_uid=$(stat -c %u "$(dirname "$abs_top_builddir")") || return 1
+  fi
+  [ "$owner_uid" -ne 0 ] || return 1
+  owner_entry=$(getent passwd "$owner_uid") || return 1
+  printf '%s\n' "$owner_entry" | cut -d: -f1
+}
+
+test_user=
+
+while [ $# -gt 0 ]; do
+  case $1 in
+    --user)
+      [ $# -ge 2 ] || {
+        usage >&2
+        exit 1
+      }
+      test_user=$2
+      shift 2
+      ;;
+    --help)
+      usage
+      exit 0
+      ;;
+    *)
+      usage >&2
+      exit 1
+      ;;
+  esac
+done
+
+[ "$(id -u)" -eq 0 ] || fail 'setup-setuid.sh must be run as root'
+
+if [ -z "$test_user" ]; then
+  if [ -n "${JAI_TEST_USER:-}" ]; then
+    test_user=$JAI_TEST_USER
+  elif [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
+    test_user=$SUDO_USER
+  elif test_user=$(default_test_user); then
+    :
+  else
+    fail 'specify --user when running as root without SUDO_USER or a non-root build directory owner'
+  fi
+fi
+
+jai_bin=$abs_top_builddir/jai
+install_root=/var/tmp/jai-test-$test_user
+install_path=$install_root/jai
+
+[ -x "$jai_bin" ] || fail "$jai_bin has not been built"
+
+test_user_uid=$(id -u "$test_user") || fail "cannot find uid for $test_user"
+test_user_gid=$(id -g "$test_user") || fail "cannot find primary gid for $test_user"
+
+prepare_install_root() {
+  if [ -e "$install_root" ]; then
+    [ -d "$install_root" ] || fail "$install_root exists but is not a directory"
+    [ ! -L "$install_root" ] || fail "$install_root must not be a symlink"
+  else
+    mkdir "$install_root"
+  fi
+
+  chown "$test_user_uid:$test_user_gid" "$install_root"
+  chmod 700 "$install_root"
+
+  [ -d "$install_root" ] || fail "$install_root is not a directory"
+  [ ! -L "$install_root" ] || fail "$install_root must not be a symlink"
+
+  owner_uid=$(stat -Lc %u "$install_root") || fail "cannot stat $install_root"
+  mode=$(stat -Lc %a "$install_root") || fail "cannot stat $install_root"
+  [ "$owner_uid" = "$test_user_uid" ] ||
+    fail "$install_root is not owned by $test_user"
+  [ "$mode" = "700" ] ||
+    fail "$install_root must have mode 0700"
+}
+
+installed_jai_is_current() {
+  [ -u "$install_path" ] || return 1
+  [ -r "$install_path" ] || return 1
+  [ -x "$install_path" ] || return 1
+  mode=$(stat -Lc %a "$install_path") || return 1
+  [ "$mode" = "4555" ] || return 1
+  [ -L "$link_path" ] || return 1
+  [ "$(readlink "$link_path")" = "$install_path" ] || return 1
+  cmp -s "$jai_bin" "$install_path" 2>/dev/null
+}
+
+update_link() {
+  rm -f "$link_path"
+  ln -s "$install_path" "$link_path"
+}
+
+prepare_install_root
+
+if installed_jai_is_current; then
+  printf '%s is up to date\n' "$install_path"
+  exit 0
+fi
+
+install -o root -g root -m 4555 "$jai_bin" "$install_path"
+update_link
+
+printf 'Installed %s\n' "$install_path"
+printf 'Linked %s -> %s\n' "$link_path" "$install_path"
+printf 'Users can remove %s when they are done\n' "$install_path"
+printf 'Run: make check\n'
tests/storage-from-conf.sh
new file mode 100755
index 0000000..ead53e0
--- /dev/null
+++ b/tests/storage-from-conf.sh
@@ -0,0 +1,27 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test xfail-storage-from-conf
+init_config
+
+STORAGE=$TEST_ROOT/storage
+mkdir -p "$STORAGE"
+
+cat >"$CONFIG_DIR/probe.conf" <<EOF
+conf .defaults
+storage $STORAGE
+jail storage-from-conf
+command /usr/bin/env
+EOF
+
+capture_in_dir "$WORKDIR" run_jai -C probe
+if [ "$CAPTURE_STATUS" -ne 0 ]; then
+  printf '%s\n' 'FAIL: storage set in a .conf file should relocate .jail files and sandbox state' >&2
+  printf '%s\n' "$CAPTURE_STDERR" >&2
+  exit 1
+fi
+
+assert_output_line "JAI_JAIL=storage-from-conf"
+assert_path_exists "$STORAGE/storage-from-conf.jail"
+assert_path_missing "$CONFIG_DIR/storage-from-conf.jail"
tests/storage.sh
new file mode 100755
index 0000000..3a41dba
--- /dev/null
+++ b/tests/storage.sh
@@ -0,0 +1,35 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test storage
+init_config
+
+STORAGE=$REAL_HOME/jai-storage-$$
+HOST_HOME_FILE=$REAL_HOME/jai-storage-home-$$
+UPPER_FILE=$STORAGE/default.changes/$(basename "$HOST_HOME_FILE")
+
+register_cleanup_path "$STORAGE"
+register_cleanup_path "$HOST_HOME_FILE"
+
+mkdir -p "$STORAGE"
+printf 'sentinel' >"$STORAGE/.sentinel"
+printf 'host' >"$HOST_HOME_FILE"
+
+capture_in_dir "$WORKDIR" run_jai --storage "$STORAGE" /bin/sh -c '
+  printf overlay > "$1"
+  if [ -e "$2/.sentinel" ]; then
+    printf exposed
+  else
+    printf hidden
+  fi
+' sh "$HOST_HOME_FILE" "$STORAGE"
+
+assert_status 0
+assert_eq "$CAPTURE_STDOUT" "hidden"
+assert_file_equals "$HOST_HOME_FILE" "host"
+assert_path_exists "$UPPER_FILE"
+assert_file_equals "$UPPER_FILE" "overlay"
+
+mount_line=$(get_mount_line "/run/jai/$REAL_USER/default.home")
+assert_contains "$mount_line" "upperdir=$STORAGE/default.changes"
tests/strict-home-grant.sh
new file mode 100755
index 0000000..4d12884
--- /dev/null
+++ b/tests/strict-home-grant.sh
@@ -0,0 +1,31 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test strict-home-grant
+init_config
+ensure_untrusted_user
+
+READ_FILE=$REAL_HOME/jai-strict-home-read-$$
+WRITE_FILE=$REAL_HOME/jai-strict-home-write-$$
+
+register_cleanup_path "$READ_FILE"
+register_cleanup_path "$WRITE_FILE"
+
+printf 'visible-from-home' >"$READ_FILE"
+rm -f "$WRITE_FILE"
+
+capture_in_dir "$REAL_HOME" run_jai -m strict -D /bin/sh -c \
+  '[ -e "$1" ] && printf visible || printf hidden' sh "$READ_FILE"
+assert_status 0
+assert_eq "$CAPTURE_STDOUT" "hidden"
+
+capture_in_dir "$REAL_HOME" run_jai -m strict -D -d "$REAL_HOME" /bin/sh -c '
+  pwd
+  cat "$1"
+  printf rewritten > "$2"
+' sh "$READ_FILE" "$WRITE_FILE"
+assert_status 0
+assert_output_line "$REAL_HOME"
+assert_output_line "visible-from-home"
+assert_file_equals "$WRITE_FILE" "rewritten"
tests/sudo-env.sh
new file mode 100755
index 0000000..8885a1c
--- /dev/null
+++ b/tests/sudo-env.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test sudo-env
+init_config
+
+capture_in_dir "$WORKDIR" run_jai /usr/bin/env
+assert_status 0
+assert_output_line "HOME=$REAL_HOME"
+assert_output_line "USER=$REAL_USER"
+assert_output_line "LOGNAME=$REAL_USER"
+assert_no_output_line "HOME=/root"
+assert_no_output_line "MAIL=/var/mail/root"
tests/teardown.sh
new file mode 100755
index 0000000..f0627ef
--- /dev/null
+++ b/tests/teardown.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+. ./common.sh
+
+setup_test teardown
+init_config
+
+HOST_HOME_FILE=$REAL_HOME/jai-teardown-home-$$
+UPPER_FILE=$CONFIG_DIR/default.changes/$(basename "$HOST_HOME_FILE")
+
+register_cleanup_path "$HOST_HOME_FILE"
+
+printf 'host' >"$HOST_HOME_FILE"
+
+capture_in_dir "$WORKDIR" run_jai /bin/sh -c '
+  printf overlay > "$1"
+  printf keep > /tmp/keep
+' sh "$HOST_HOME_FILE"
+assert_status 0
+
+assert_mount_exists "/run/jai/$REAL_USER/default.home"
+assert_root_path_exists "/run/jai/$REAL_USER/tmp/default/keep"
+assert_path_exists "$UPPER_FILE"
+
+capture run_jai -u
+assert_status 0
+assert_no_mount "/run/jai/$REAL_USER/default.home"
+assert_root_path_missing "/run/jai/$REAL_USER/default.home"
+assert_root_path_missing "/run/jai/$REAL_USER/tmp/default"
+assert_path_exists "$UPPER_FILE"