#!/usr/bin/env bash # tests/unit/03_argv.sh -- the policy actually reaches the podman invocation. source "$TESTS/lib.sh" PROJ="$WORK/proj"; mkdir -p "$PROJ"; cd "$PROJ" || exit 1 export AISB_CONFIG_DIR="$WORK/cfg-empty"; mkdir -p "$AISB_CONFIG_DIR" A="--allow example.com" # Every run in this file is a preview. What that is worth is asserted at the end, against # the set of containers that existed before it started. snapshot_containers t_name "isolation flags are always on" sb --dry-run $A assert_contains "--cap-drop=ALL" "all capabilities dropped" assert_contains "no-new-privileges" "no new privileges" assert_contains "--userns keep-id" "uid mapped back to the host user" assert_contains "--pids-limit 512" "pid limit" assert_contains "--memory 4g" "memory limit" assert_contains "--cpus 4" "cpu limit" assert_contains "/tmp:rw,nosuid,nodev" "/tmp is a nosuid tmpfs" assert_contains "--hostname aisb" "hostname" assert_contains "--rm" "container is removed on exit" assert_contains "--stop-signal SIGTERM" "clean shutdown signal" t_name "network modes" sb --dry-run --net none assert_contains "--network none" "none" sb --dry-run --net full assert_contains "--network pasta" "full uses pasta with no filter" assert_absent "--cap-add=NET_ADMIN" "full mode needs no NET_ADMIN" sb --dry-run $A assert_contains "--network pasta:--map-host-loopback,100.64.0.1" "allow mode maps a host-loopback door for the gate" assert_contains "--cap-add=NET_ADMIN" "allow mode grants NET_ADMIN to the entrypoint only" assert_contains "HTTP_PROXY=http://100.64.0.1:18081" "proxy env points at the gate" assert_contains "NO_PROXY=localhost" "localhost is never proxied" sb --dry-run $A --proxy 10.9.8.7:3128 assert_contains "HTTP_PROXY=http://10.9.8.7:3128" "--proxy overrides the gate" sb --dry-run $A --no-gate assert_absent "HTTP_PROXY=" "--no-gate sets no proxy" t_name "filesystem" # Where the cwd lands depends on the host path, so the expected destination is asked of # the same function the script uses instead of being hardcoded here. PROJ_DEST="$([[ -n "$(mirror_block_of "$PROJ")" ]] && echo /project || echo "$PROJ")" sb --dry-run $A assert_contains "$PROJ:$PROJ_DEST" "cwd is mounted at its own path or the fallback" assert_contains "--workdir $PROJ_DEST" "cwd is the working directory" assert_contains "/run/aisb:ro" "policy dir is mounted read-only" sb --dry-run $A --no-cwd assert_absent "$PROJ" "--no-cwd mounts nothing" sb --dry-run $A --workdir /src assert_contains "$PROJ:/src" "--workdir changes the mount point" mkdir -p "$WORK/extra" sb --dry-run $A --mount "$WORK/extra" assert_contains "$WORK/extra:/mnt/extra" "--mount defaults to /mnt/" sb --dry-run $A --mount "$WORK/extra:/ex:ro" assert_contains "$WORK/extra:/ex" "--mount with explicit destination" assert_contains "ro" " ... and ro option present" sb --dry-run $A --read-only assert_contains "--read-only" "--read-only" sb --dry-run $A assert_contains "type=volume,source=aisb-home,target=/home/sandbox" "home cache volume by default" sb --dry-run $A --ephemeral-home assert_contains "/home/sandbox:rw,nosuid,nodev" "--ephemeral-home uses a tmpfs home" assert_absent "type=volume,source=aisb-home" "--ephemeral-home drops the volume" t_name "the container names the directory what the host calls it" # Mirroring the host path is what makes pwd, an error message and a harness' window title # name *this* project. It is a decision about the host path, so both branches need a real # directory: $TMPDIR is a path the sandbox owns, a per-user tree is not. mirrorable_dir || true MIRROR_DIR="$USER_MIRROR_DIR" if [[ -z "$MIRROR_DIR" ]]; then _ok "no writable host path here may be mirrored ($HOME is itself inside a sandbox?) -- skipped" else cd "$MIRROR_DIR" || exit 1 sb --dry-run $A assert_contains "$MIRROR_DIR:$MIRROR_DIR" "the cwd is mounted at the path it has on the host" assert_contains "--workdir $MIRROR_DIR" "and worked in at that path" assert_absent "# workdir:" "nothing to announce when the path mirrors" sb --dry-run $A --print-policy assert_eq "" "$(printf '%s' "$OUT" | grep '^workdir_note=' || true)" "and no note in the policy" sb --dry-run $A --workdir /src assert_contains "$MIRROR_DIR:/src" "--workdir still picks the mount point over mirroring" assert_contains "--workdir /src" "--workdir is what reaches podman" sb --dry-run $A --workdir auto assert_contains "--workdir $MIRROR_DIR" "'auto' is the default, spelled out" cd "$PROJ" || exit 1 fi t_name "a host path the sandbox owns is not mirrored, and says so" # /tmp is the guaranteed case: the sandbox mounts a tmpfs there, and podman puts that # tmpfs on top of a bind of the same path -- so a mirrored /tmp project would come out as # an empty tmpfs. Measured, see docs/networking.md. cd /tmp || exit 1 sb --dry-run $A assert_contains "--workdir /project" "/tmp falls back to the image's own directory" assert_contains "-v /tmp:/project" "and mounts the cwd there instead" assert_contains "# workdir: cannot mirror /tmp" "--dry-run explains the fallback" assert_contains "the sandbox owns /tmp*" "naming what it would have buried" assert_contains "--workdir DIR to choose" "and the way to decide otherwise" sb_env AI_API_TOKEN=canary-not-a-real-token --dry-run $A --env AI_API_TOKEN assert_absent "canary-not-a-real-token" "the note carries no values, only the decision" sb --dry-run $A --print-policy assert_contains "workdir=/project" "the policy says which directory was chosen" assert_contains "workdir_note=" "and why" assert_contains "cwd=/tmp" "while still printing the host path" cd "$PROJ" || exit 1 t_name "which host paths may be mirrored is a decision about the container's filesystem" # The refusals are about what lives at a path *inside* the image, and a test cannot create # /usr or /home/sandbox. So ask the function itself, extracted from the script, about the # paths it was written for -- the same trick 06_entrypoint.sh uses for the entrypoint. [[ -n "$(aisb_fn mirror_block_of)" ]] \ && _ok "the mirror decision is one function in the script" \ || _bad "the mirror decision is one function in the script" # Blocked: everything the base image owns, plus this sandbox's own mounts and home. for p in / /usr/src/proj /var/lib/pg/data /opt/stack/app /etc/proj /tmp/build/proj \ /run/user/1000/proj /home /home/sandbox /home/sandbox/work /bin/proj /boot/efi \ /dev/shm/x /lib/x86_64-linux-gnu /lib32/x /lib64/ld /proc/1/cwd /root/proj \ /sbin/init /sys/firmware; do b="$(mirror_block_of "$p")" if [[ -n "$b" ]]; then _ok "$p is not mirrored (would bury $b)" else _bad "$p is not mirrored" "it was mirrored"; fi done assert_eq "/tmp" "$(mirror_block_of /tmp/a/b)" "the note names the top-level path, not the project" assert_eq "/home/sandbox" "$(mirror_block_of /home/sandbox/work)" "the container home is named as itself" assert_eq "/" "$(mirror_block_of /)" "the whole root is refused" # Free: per-user trees under /home, and the hierarchy the image leaves empty. for p in /home/you/Projects/foo /home/dev /home/sandboxer/proj /mnt/data/proj /srv/www \ /media/usb/proj /code/x /work/proj /data/proj /home/sandboxish/x; do b="$(mirror_block_of "$p")" if [[ -z "$b" ]]; then _ok "$p mirrors to itself" else _bad "$p mirrors to itself" "refused because of $b"; fi done t_name "a project path podman cannot express is refused with a reason" # Measured: podman answers `-v /a:b:/project` with `invalid option type "/project"`, which # is a strange thing to read when all you typed was `pi`. Mirroring puts the same string in # the destination too, so it is refused before podman is involved. COLON="$WORK/a:b"; mkdir -p "$COLON"; cd "$COLON" || exit 1 sb --dry-run $A assert_rc 1 "a ':' in the project path is refused" assert_contains "podman cannot parse" "and says whose parser" assert_contains "--no-cwd" "and names the way out" sb --dry-run $A --no-cwd assert_rc 0 "--no-cwd needs nothing mounted, so the path is fine" cd "$PROJ" || exit 1 t_name "--workdir takes a container path or 'auto', nothing else" sb --dry-run $A --workdir relative/dir assert_rc 1 "a relative --workdir is refused" assert_contains "must be an absolute path or 'auto'" "and says what it wanted" sb --dry-run $A --workdir / assert_rc 1 "--workdir / is refused" assert_contains "over the whole container" "with the reason" sb --dry-run $A --workdir /src/ assert_contains "$PROJ:/src" "a trailing slash is not part of the destination" # An explicit path onto something the sandbox owns is honoured -- it was asked for -- but # the hiding rule does not stop applying because a human chose the path, so it is warned. sb --dry-run $A --workdir /tmp/proj assert_rc 0 "--workdir onto a path the sandbox owns is still honoured" assert_contains "$PROJ:/tmp/proj" "podman is told what was asked for" assert_contains "warning: --workdir /tmp/proj" "and the covering is announced" assert_contains "/tmp is a tmpfs" "with the reason" sb --dry-run $A --workdir /opt/proj assert_contains "the sandbox owns /opt*" "the same for a directory the image owns" sb --dry-run $A --workdir /srv/app assert_absent "warning: --workdir" "a path nothing owns is chosen in silence" sb_env AISB_PROJECT_DEST=/srv/app --dry-run $A assert_contains "$PROJ:/srv/app" "AISB_PROJECT_DEST still chooses it" mkdir -p "$WORK/cfg-wd" printf 'workdir = /srv/cfg\n' > "$WORK/cfg-wd/config" # No subshell here: OUT and the counters have to survive it. AISB_CONFIG_DIR_SAVED="$AISB_CONFIG_DIR"; export AISB_CONFIG_DIR="$WORK/cfg-wd" sb --dry-run $A assert_contains "$PROJ:/srv/cfg" "the config file chooses it too" export AISB_CONFIG_DIR="$AISB_CONFIG_DIR_SAVED" t_name "labels, limits, extra args" sb --dry-run $A --label team=x assert_contains "--label team=x" "--label" assert_contains "aisb.net=allow" "session label" sb --dry-run $A --cpus 12 --memory 8g --pids 99 assert_contains "--cpus 12" "custom cpus" assert_contains "--memory 8g" "custom memory" assert_contains "--pids-limit 99" "custom pids" sb_env "AISB_EXTRA_ARGS=--label from=extra" --dry-run $A assert_contains "--label from=extra" "AISB_EXTRA_ARGS is appended" t_name "everything lands before the image; the command comes last" sb --dry-run $A --label zzz=1 -- echo hello line="$(printf '%s' "$OUT" | tail -1)" pos_img="$(printf '%s' "$line" | tr ' ' '\n' | grep -n -m1 'localhost/ai-sandbox' | cut -d: -f1)" pos_lbl="$(printf '%s' "$line" | tr ' ' '\n' | grep -n -m1 'zzz=1' | cut -d: -f1)" pos_cmd="$(printf '%s' "$line" | tr ' ' '\n' | grep -n -m1 '^hello$' | cut -d: -f1)" if [[ -n "$pos_img" && -n "$pos_lbl" && $pos_lbl -lt $pos_img ]]; then _ok "extra flags precede the image"; else _bad "extra flags precede the image" "img=$pos_img label=$pos_lbl"; fi if [[ -n "$pos_cmd" && -n "$pos_img" && $pos_img -lt $pos_cmd ]]; then _ok "the command follows the image"; else _bad "the command follows the image" "img=$pos_img cmd=$pos_cmd"; fi t_name "dry run is side-effect free" sb --dry-run $A # Ask about the container *this* invocation would have created, not about every sandbox on # the machine: another session that happens to be running is not this test starting one. # The session id is unique per run, so its label is the hermetic question. SID="$(printf '%s' "$OUT" | tr ' ' '\n' | sed -n 's/^aisb\.session=//p' | head -1)" [[ -n "$SID" ]] && _ok "the dry run names its session" || _bad "the dry run names its session" CREATED="$(podman ps -a --filter "label=aisb.session=$SID" --format '{{.Names}}' | wc -l | tr -d ' ')" DIRS="$(ls -A "$AISB_STATE_DIR/sessions" 2>/dev/null | wc -l | tr -d ' ')" assert_eq "$CREATED" 0 "dry run created no container, under any name" assert_eq "$DIRS" 0 "dry run left no session directory behind" assert_no_new_containers "and no preview in this file created one either" t_name "attachment: stdin is a choice, and an unattended run does not want it" # -t and -i are decided from the real stdin/stdout, and the test runner's are pipes. The # pipe case is checked either way; the attached case needs a pty, which lib.sh builds from # 'script' or python3 and reports as skipped when the host has neither. inv_line() { printf '%s' "$OUT" | tr -d '\r' | tail -1; } has_opt() { inv_line | grep -qE "(^| )$1( |$)"; } sb_pty() { pty_run "$AISB" --dry-run --net none "$@"; } sb --dry-run --net none if has_opt "-i" || has_opt "-t"; then _bad "pipes get neither" "$(inv_line)"; else _ok "pipes get neither: nothing is attached to a terminal that is not there"; fi sb --dry-run --net none --interactive if has_opt "-i"; then _ok "--interactive still forces it on with pipes"; else _bad "--interactive still forces it on with pipes" "$(inv_line)"; fi if ! pty_available; then _ok "neither 'script' nor python3 here -- the attached cases are skipped" else sb_pty assert_rc 0 "on a pty" if has_opt "-i" && has_opt "-t"; then _ok "a terminal run is attached, which is what an interactive shell expects"; else _bad "a terminal run is attached" "$(inv_line)"; fi sb_pty --no-interactive assert_rc 0 "--no-interactive" if has_opt "-i"; then _bad "--no-interactive drops -i" "$(inv_line)"; else _ok "--no-interactive drops -i: the command cannot read this terminal"; fi if has_opt "-t"; then _ok "--no-interactive leaves the TTY alone (it is a separate choice)"; else _bad "--no-interactive leaves the TTY alone" "$(inv_line)"; fi sb_pty --no-tty --no-interactive if has_opt "-t" || has_opt "-i"; then _bad "--no-tty --no-interactive detaches both" "$(inv_line)"; else _ok "--no-tty --no-interactive detaches both"; fi sb_pty --strict-args --no-interactive assert_rc 0 "--no-interactive is a real flag, not a pass-through to the harness" if has_opt "-i"; then _bad "--no-interactive is parsed, not forwarded" "$(inv_line)"; else _ok "--no-interactive is parsed, not forwarded"; fi fi