#!/usr/bin/env bash # tests/unit/06_entrypoint.sh -- the container-side enforcement script. 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" # Extract the entrypoint exactly as it is baked into the image. EP="$WORK/entrypoint.sh" "$AISB" --print-entrypoint > "$EP" 2>"$WORK/ep.err"; RC=$? assert_eq 0 "$RC" "the embedded entrypoint can be printed" bash -n "$EP" && _ok "entrypoint is syntactically valid bash" || _bad "entrypoint is syntactically valid bash" grep -q 'set -Eeuo pipefail' "$EP" && _ok "entrypoint is strict" || _bad "entrypoint is strict" t_name "the image is described by the script itself" sb --print-containerfile assert_contains "FROM docker.io/library/ubuntu:24.04" "ubuntu 24.04 base" assert_contains "nftables" "image ships nftables" assert_contains "util-linux" "image ships setpriv (util-linux)" assert_contains "sha256sum -c" "node tarball is checksum verified" assert_contains "useradd" "image creates the sandbox user" assert_contains 'ENTRYPOINT ["/usr/local/lib/aisb/entrypoint.sh"]' "entrypoint is baked in" assert_contains "WORKDIR /project" "project is the default workdir" t_name "generated ruleset shape" # Run the rule generator outside a container (nft is not needed to render it). POL="$WORK/mnt"; mkdir -p "$POL" cat > "$POL/policy.env" < "$POL/direct4.list" printf '::1 8080\n' > "$POL/direct6.list" RULES="$AISB_STATE_DIR/rules.f" # Render the ruleset by extracting the generator and feeding it the policy above. GEN="$WORK/gen.sh" awk '/^gen_rules\(\) \{/{f=1} f{print} f&&/^\}$/{exit}' "$EP" > "$GEN" { printf 'POLICY_DIR=%q\nAISB_PROXY_IPS="100.64.0.1"\nAISB_PROXY_PORTS="18081"\nAISB_ALLOW_ICMP=1\nAISB_ALLOW_DNS=0\n' "$POL" printf 'gen_rules\n'; } >> "$GEN" bash "$GEN" > "$RULES" 2>"$WORK/gen.err" && _ok "rules render" || _bad "rules render" "$(tail -3 "$WORK/gen.err")" grep -q 'table inet aisb' "$RULES" && _ok "inet table (covers v4 and v6)" || _bad "inet table" grep -q 'policy drop' "$RULES" && _ok "default policy is drop" || _bad "default policy drop" grep -q 'aisb-default-drop' "$RULES" && _ok "explicit default-drop rule exists" || _bad "default-drop rule" grep -q 'oifname "lo" accept' "$RULES" && _ok "loopback stays usable" || _bad "loopback accept" grep -q 'ip daddr 10.0.0.5 tcp dport { 5432 }' "$RULES" && _ok "per-entry host:port rule" || _bad "host:port rule: $(grep -c . "$RULES") lines" grep -q 'ip daddr 10.1.0.0/16 tcp dport { 80,443 }' "$RULES" && _ok "CIDR rule with default ports" || _bad "CIDR rule" grep -q 'ip6 daddr ::1 tcp dport { 8080 }' "$RULES" && _ok "ipv6 rule" || _bad "ipv6 rule" grep -q 'ip daddr 100.64.0.1 tcp dport 18081' "$RULES" && _ok "gate endpoint is allowed" || _bad "gate rule" grep -q 'aisb-est' "$RULES" && _ok "established traffic accepted" || _bad "established rule" grep -c 'accept comment "aisb-direct"' "$RULES" | grep -qx 3 && _ok "exactly 3 direct rules" || _bad "direct rule count: $(grep -c 'aisb-direct' "$RULES")" t_name "no DNS hole when the gate is in front" grep -E 'th dport 53' "$RULES" >/dev/null && _bad "DNS is closed in gate mode" || _ok "DNS is closed in gate mode" t_name "the seeding script survives being passed in single quotes" # Everything between `bash -c '` and `' aisb-seed` is one single-quoted argument of the # outer script. One quote inside it closes that argument early and the rest of the file # silently becomes code in the *outer* shell -- valid bash, wrong program, so neither # `bash -n` nor shellcheck notices. Hence this test. INNER="$WORK/seeding-inner.sh" awk "/setpriv .*bash -c '/{f=1;next} /^ *' aisb-seed/{f=0} f" "$EP" > "$INNER" [[ -s "$INNER" ]] && _ok "the seeding script was extracted" || _bad "the seeding script was extracted" if grep -q "'" "$INNER"; then _bad "it contains no single quote" "found: $(grep -n "'" "$INNER" | head -2 | tr '\n' ' ')" else _ok "it contains no single quote" fi bash -n "$INNER" && _ok "and it is valid bash on its own" || _bad "and it is valid bash on its own" grep -q '\$lvl' "$INNER" && _ok "the thinking level is enforced inside it" || _bad "thinking level enforced" t_name "settings.json is merged per key, not managed whole" # Run the merge exactly as the entrypoint does, with the program extracted from the image # script: T = what the sandbox has, B = what we last wrote, N = what the host says now. MJQ="$WORK/merge.jq" awk '/^SETTINGS_MERGE_JQ=/{f=1;next} f && /^.$/{exit} f' "$EP" > "$MJQ" [[ "$(wc -l < "$MJQ")" -gt 3 ]] && _ok "the merge program was extracted" || _bad "the merge program was extracted" M_IN="$WORK/m"; mkdir -p "$M_IN" cat > "$M_IN/new.json" <<'EOF' { "defaultProvider": "gateway", "defaultModel": "turbo", "theme": "dark/dark", "thinkingBudgets": { "xhigh": 65536 } } EOF cat > "$M_IN/wrote.json" <<'EOF' { "defaultProvider": "gateway", "defaultModel": "turbo", "theme": "dark/dark", "thinkingBudgets": { "xhigh": 65536 } } EOF cat > "$M_IN/sandbox.json" <<'EOF' { "defaultProvider": "gateway", "defaultModel": "turbo", "theme": "light/light", "thinkingBudgets": { "xhigh": 65536 }, "lastChangelogVersion": "1.4.0", "mine": 7 } EOF merge_out() { jq -c -n --slurpfile T "$1" --slurpfile B "$2" --slurpfile N "$3" -f "$MJQ"; } M="$(merge_out "$M_IN/sandbox.json" "$M_IN/wrote.json" "$M_IN/new.json")" [[ "$(printf '%s' "$M" | jq -r .theme)" == "light/light" ]] \ && _ok "a key edited in the sandbox stays edited" || _bad "sandbox edit kept: $M" [[ "$(printf '%s' "$M" | jq -r .lastChangelogVersion)" == "1.4.0" ]] \ && _ok "a key only pi wrote survives" || _bad "sandbox-only key: $M" [[ "$(printf '%s' "$M" | jq -r .mine)" == "7" ]] \ && _ok "and so does one the sandbox invented" || _bad "invented key: $M" # host moves two keys, drops a third, keeps one the sandbox changed cat > "$M_IN/new2.json" <<'EOF' { "defaultProvider": "gateway", "defaultModel": "deepthink", "thinkingBudgets": { "xhigh": 131072 } } EOF M2="$(merge_out "$M_IN/sandbox.json" "$M_IN/wrote.json" "$M_IN/new2.json")" [[ "$(printf '%s' "$M2" | jq -r .defaultModel)" == "deepthink" ]] \ && _ok "an untouched key follows the host again" || _bad "host key arrives: $M2" [[ "$(printf '%s' "$M2" | jq -r .thinkingBudgets.xhigh)" == "131072" ]] \ && _ok "including a nested one (whole key, whole value)" || _bad "nested key: $M2" [[ "$(printf '%s' "$M2" | jq -r 'has("theme")')" == "true" ]] \ && _ok "a key the host dropped stays if the sandbox had changed it" || _bad "dropped-but-edited key: $M2" # the same drop against a file the sandbox never touched: it goes away, as it did when # the whole file was re-seeded -- otherwise settings only ever accumulate cat > "$M_IN/clean.json" <<'EOF' { "defaultProvider": "gateway", "defaultModel": "turbo", "theme": "dark/dark", "thinkingBudgets": { "xhigh": 65536 } } EOF M3="$(merge_out "$M_IN/clean.json" "$M_IN/wrote.json" "$M_IN/new2.json")" [[ "$(printf '%s' "$M3" | jq -r 'has("theme")')" == "false" ]] \ && _ok "an untouched key the host dropped is dropped" || _bad "untouched dropped key: $M3" # no base copy (the first run after this shipped, or .wrote deleted): the host wins on # every key it has, which is what repairs a settings file pi had frozen. M4="$(merge_out "$M_IN/sandbox.json" /dev/null "$M_IN/new.json")" [[ "$(printf '%s' "$M4" | jq -r '.thinkingBudgets.xhigh')" == "65536" ]] \ && _ok "with no base, host preferences come back (the frozen-file repair)" || _bad "no-base merge: $M4" [[ "$(printf '%s' "$M4" | jq -r .lastChangelogVersion)" == "1.4.0" ]] \ && _ok "and pi's own keys are kept" || _bad "no-base, pi key: $M4" printf '{oops' > "$M_IN/broken.json" jq -n --slurpfile T "$M_IN/broken.json" --slurpfile B /dev/null --slurpfile N "$M_IN/new.json" \ -f "$MJQ" > /dev/null 2>&1 \ && _bad "unparseable input fails loudly for the caller to handle" || _ok "unparseable input fails loudly for the caller to handle" t_name "privilege drop is the last thing that happens" tail -5 "$EP" | grep -q 'setpriv' && _ok "exec setpriv at the end" || _bad "setpriv last: $(tail -3 "$EP" | tr '\n' ' ')" grep -q -- '--bounding-set=-all' "$EP" && _ok "bounding set cleared" || _bad "bounding set cleared" grep -q -- '--inh-caps=-all' "$EP" && _ok "inheritable caps cleared" || _bad "inheritable caps cleared" grep -q -- '--no-new-privs' "$EP" && _ok "no new privileges" || _bad "no-new-privs" grep -q -- '--reuid=' "$EP" && _ok "uid switched" || _bad "reuid" grep -q 'fail "policy file' "$EP" && _ok "missing policy = refuse to start" || _bad "fail closed on missing policy" grep -q 'refusing to start\|Refusing to run unenforced' "$EP" && _ok "missing NET_ADMIN = refuse to start" || _bad "fail closed without NET_ADMIN"