diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac8f487e..1e3e2f02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -519,13 +519,12 @@ jobs: $env:USERPROFILE = $OriginalUserProfile } - # Non-required Ubuntu 26.04 probe for #1626. ubuntu-latest is still 24.04, so - # the required test leg's name and scripts stay put. continue-on-error keeps - # a red finding from failing the workflow. This job only records host policy - # and the same bwrap command the 24.04 leg runs; it does not write the - # userns sysctl. - ubuntu-26-04-bwrap-diagnose: - name: ubuntu-26-04-bwrap-diagnose + # Same script as the test job's Linux bwrap step (#1626, measured on + # #1641 run 38352458013). continue-on-error keeps the job off the required + # check set. The assertions are the strict 26.04+ branch of that script. + # This job does not write kernel.apparmor_restrict_unprivileged_userns. + ubuntu-26-04-bwrap: + name: ubuntu-26.04 bwrap runs-on: ubuntu-26.04 continue-on-error: true timeout-minutes: 15 @@ -536,163 +535,9 @@ jobs: with: persist-credentials: false - - name: Install distro bubblewrap (26.04 has no noble pin) + - name: Version-aware bwrap AppArmor proof shell: bash - run: | - set -u - sudo apt-get update - echo "--- apt-cache policy bubblewrap ---" - apt-cache policy bubblewrap - if apt-cache show 'bubblewrap=0.9.0-1ubuntu0.3' >/dev/null 2>&1; then - echo "noble_pin=present" - else - echo "noble_pin=absent bubblewrap=0.9.0-1ubuntu0.3" - fi - sudo apt-get install --yes --no-install-recommends bubblewrap apparmor-utils - echo "bwrap_path=$(command -v bwrap)" - bwrap --version - dpkg-query -W -f='installed_bubblewrap=${Version}\n' bubblewrap - dpkg-query -W -f='installed_${Package}=${Version}\n' apparmor apparmor-utils apparmor-profiles 2>/dev/null || true - - - name: Record stock AppArmor, then the cyclaw-bwrap load - if: ${{ !cancelled() }} - shell: bash - run: | - set -u - set +e - set +o pipefail - - section() { - printf '\n===== %s =====\n' "$1" - } - - section "host" - awk -F= '/^(PRETTY_NAME|VERSION_ID|VERSION_CODENAME)=/{gsub(/"/, "", $2); print}' /etc/os-release - uname -a - apparmor_parser --version 2>&1 || true - aa-enabled 2>&1 || true - aa-status --version 2>&1 || true - - section "sysctl" - for key in \ - kernel.apparmor_restrict_unprivileged_userns \ - kernel.apparmor_restrict_unprivileged_unconfined \ - kernel.unprivileged_userns_clone \ - user.max_user_namespaces - do - val="$(sysctl -n "$key" 2>/dev/null || echo missing)" - printf '%s=%s\n' "$key" "$val" - done - - section "bwrap binary" - ls -l /usr/bin/bwrap || true - getcap /usr/bin/bwrap 2>/dev/null || true - bwrap --help 2>&1 | grep -E -n 'unshare-all|unshare-net|share-net|cap-drop' || true - - section "apparmor paths" - sudo find /etc/apparmor.d /usr/share/apparmor \ - \( -iname '*bwrap*' -o -iname '*userns*' \) -print 2>/dev/null | sort || true - - section "profile texts" - while IFS= read -r path; do - [ -n "$path" ] || continue - printf '----- %s -----\n' "$path" - sudo ls -l "$path" || true - if [ -L "$path" ]; then - printf 'symlink_target=%s\n' "$(sudo readlink -f "$path" 2>/dev/null || true)" - fi - sudo awk 'NR<=260 {print}' "$path" || true - done < <(sudo find /etc/apparmor.d /usr/share/apparmor \ - \( -iname '*bwrap*' -o -iname '*userns*' \) -print 2>/dev/null | sort) - - loaded_profiles() { - section "loaded profiles $1" - sudo awk 'BEGIN{IGNORECASE=1} /bwrap|userns/ {print}' \ - /sys/kernel/security/apparmor/profiles 2>/dev/null || true - sudo aa-status 2>&1 | awk 'BEGIN{IGNORECASE=1} /bwrap|userns|profiles are|in enforce|in complain/ {print}' || true - } - - dump_denials() { - section "kernel denials $1" - sudo dmesg -T 2>/dev/null | awk 'BEGIN{IGNORECASE=1} /apparmor|bwrap/ {print}' | tail -n 160 || true - sudo journalctl -k --no-pager -n 300 2>/dev/null | awk 'BEGIN{IGNORECASE=1} /apparmor|bwrap/ {print}' | tail -n 160 || true - } - - probe() { - name="$1" - shift - err="${RUNNER_TEMP}/bwrap-probe-${name}.err" - out="${RUNNER_TEMP}/bwrap-probe-${name}.out" - section "probe ${name}" - printf 'cmd:' - printf ' %q' "$@" - printf '\n' - "$@" >"$out" 2>"$err" - printf 'exit:%s\n' "$?" - printf 'stdout:\n' - cat "$out" || true - printf 'stderr:\n' - cat "$err" || true - } - - loaded_profiles "before" - printf 'runner_attr=' - tr '\0' ' ' < /proc/self/attr/current 2>/dev/null || true - printf '\n' - sudo dmesg -c >/dev/null 2>&1 || true - - probe stock-full bwrap --die-with-parent --new-session --unshare-all --cap-drop ALL \ - --ro-bind / / --proc /proc --dev /dev /bin/true - probe stock-no-capdrop bwrap --die-with-parent --new-session --unshare-all \ - --ro-bind / / --proc /proc --dev /dev /bin/true - probe stock-share-net bwrap --die-with-parent --new-session --unshare-all --share-net --cap-drop ALL \ - --ro-bind / / --proc /proc --dev /dev /bin/true - probe stock-user-net bwrap --die-with-parent --unshare-user --unshare-net --cap-drop ALL \ - --ro-bind / / --proc /proc --dev /dev /bin/true - probe stock-user-only bwrap --die-with-parent --unshare-user --cap-drop ALL \ - --ro-bind / / --proc /proc --dev /dev /bin/true - - section "child label under stock" - bwrap --die-with-parent --new-session --unshare-all --cap-drop ALL \ - --ro-bind / / --proc /proc --dev /dev \ - /bin/sh -c 'printf "child_attr="; tr "\0" " " < /proc/self/attr/current; printf "\n"; if [ -r /proc/self/attr/apparmor/current ]; then printf "child_apparmor="; tr "\0" " " < /proc/self/attr/apparmor/current; printf "\n"; fi; grep -E "^Cap(Eff|Prm|Bnd|Inh):" /proc/self/status' - printf 'child_label_exit:%s\n' "$?" - - section "unprofiled copy" - copy="${RUNNER_TEMP}/bwrap-unprofiled" - cp -a /usr/bin/bwrap "$copy" - chmod 755 "$copy" - probe unprofiled-copy "$copy" --die-with-parent --new-session --unshare-all --cap-drop ALL \ - --ro-bind / / --proc /proc --dev /dev /bin/true - if command -v unshare >/dev/null 2>&1; then - probe unshare-user-net unshare --user --map-root-user --net /bin/true - else - echo "unshare_missing=1" - fi - dump_denials "after-stock" - - section "load cyclaw-bwrap" - sudo install -m 0644 deploy/apparmor/cyclaw-bwrap /etc/apparmor.d/cyclaw-bwrap - parser_out="${RUNNER_TEMP}/apparmor-parser.out" - sudo apparmor_parser -r /etc/apparmor.d/cyclaw-bwrap 2>&1 | tee "$parser_out" - printf 'apparmor_parser_exit:%s\n' "${PIPESTATUS[0]}" - printf 'parser_output:\n' - cat "$parser_out" || true - loaded_profiles "after-cyclaw-load" - sudo dmesg -c >/dev/null 2>&1 || true - probe after-cyclaw-full bwrap --die-with-parent --new-session --unshare-all --cap-drop ALL \ - --ro-bind / / --proc /proc --dev /dev /bin/true - dump_denials "after-cyclaw" - - section "unload cyclaw-bwrap" - sudo apparmor_parser -R /etc/apparmor.d/cyclaw-bwrap - printf 'apparmor_parser_remove_exit:%s\n' "$?" - sudo rm -f /etc/apparmor.d/cyclaw-bwrap - loaded_profiles "after-unload" - probe after-unload-full bwrap --die-with-parent --new-session --unshare-all --cap-drop ALL \ - --ro-bind / / --proc /proc --dev /dev /bin/true - - echo "DIAGNOSIS_COMPLETE" + run: bash scripts/ci/bwrap-apparmor-check.sh test: name: ${{ matrix.os }} @@ -713,17 +558,22 @@ jobs: # because torch==2.13.0+cpu 404s on https://download.pytorch.org/whl/cpu # for macOS -- confirmed on this matrix leg's first run, and documented # in setup-guide.md. - # Native bwrap confinement is proven on both Linux legs. ubuntu-latest - # (24.04) is the required check: stock AppArmor there refuses bwrap's - # user namespaces, so the leg proves the refusal, loads the bwrap-scoped - # profile deploy/apparmor/cyclaw-bwrap, then proves confinement. It never - # relaxes the global userns sysctl. ubuntu-22.04 proves confinement under - # stock policy. Each check is named for its real runner. - # GitHub moves ubuntu-latest to Ubuntu 26 starting 2026-10-19. If the - # stock refusal changes there, the refusal step below fails loudly on - # purpose; re-check it then rather than pinning (a pin renames the check). + # Native bwrap confinement is proven on both Linux legs. Check names + # stay ubuntu-latest and ubuntu-22.04. Which proof runs is VERSION_ID + # from /etc/os-release inside scripts/ci/bwrap-apparmor-check.sh, shared + # with the non-required ubuntu-26.04 job: + # 22.04 keeps the jammy pin and stock confinement. + # 24.04 keeps the noble pin, the stock refusal, cyclaw-bwrap, then + # confinement. It never writes the userns sysctl. + # 26.04+ does not load cyclaw-bwrap and does not install the noble + # pin. It requires the sysctl to read 1, Ubuntu's bwrap profile in + # enforce mode, a non-/usr/bin copy to be refused, and /usr/bin/bwrap + # confinement to pass. + # Any other version fails the job. + # GitHub moves ubuntu-latest to Ubuntu 26 between 2026-10-19 and + # 2026-11-19. The required check name stays ubuntu-latest. os: [ubuntu-latest, ubuntu-22.04, windows-latest, macos-latest] - # All four legs (Linux 24.04 and 22.04, Windows, macOS) are release gates. + # All four legs (ubuntu-latest, ubuntu-22.04, Windows, macOS) are release gates. # Platform-specific skips are reported below with `pytest -rs`; a failing # Windows result is not masked. @@ -732,70 +582,15 @@ jobs: with: persist-credentials: false - - name: Install Linux verification filesystem/PID/network sandbox + - name: Version-aware bwrap AppArmor proof + # 22.04, 24.04, and 26.04+ share scripts/ci/bwrap-apparmor-check.sh with + # the non-required ubuntu-26.04 job. The script never writes + # kernel.apparmor_restrict_unprivileged_userns. 24.04 still proves the + # stock refusal, loads deploy/apparmor/cyclaw-bwrap, then proves + # confinement. 26.04+ does not load that profile. if: runner.os == 'Linux' shell: bash - run: | - # Version pin (apt has no digest pin). ubuntu-latest is 24.04 until - # GitHub's 2026-10-19 move to 26; re-check the noble pin then. - case "${{ matrix.os }}" in - ubuntu-22.04) ver='0.6.1-1ubuntu0.3' ;; - *) ver='0.9.0-1ubuntu0.3' ;; # ubuntu-latest (24.04 noble) - esac - sudo apt-get update - sudo apt-get install --yes --no-install-recommends "bubblewrap=${ver}" - bwrap --version - test "$(bwrap --version)" = "bubblewrap ${ver%%-*}" - - - name: Prove stock AppArmor refuses bwrap (24.04 leg) - # Stock 24.04 sets kernel.apparmor_restrict_unprivileged_userns=1, so - # bwrap cannot create its namespaces until a profile grants it userns. - # This is what a stock 24.04 user sees, and CyClaw must fail closed on it. - # If the probe unexpectedly succeeds, the profile below proves nothing. - # Both preconditions are asserted, so neither a relaxed image nor an - # unrelated bwrap failure can pass for "refused". - if: matrix.os == 'ubuntu-latest' - shell: bash - run: | - restrict="$(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || echo missing)" - echo "kernel.apparmor_restrict_unprivileged_userns=${restrict}" - if [ "${restrict}" != "1" ]; then - echo "::error::expected stock kernel.apparmor_restrict_unprivileged_userns=1, got '${restrict}'" - exit 1 - fi - probe_err="${RUNNER_TEMP}/bwrap-stock-probe.err" - if bwrap --die-with-parent --new-session --unshare-all --cap-drop ALL \ - --ro-bind / / --proc /proc --dev /dev /bin/true 2>"${probe_err}"; then - echo "::error::bwrap succeeded under stock AppArmor; expected a refusal before the cyclaw-bwrap profile loads" - exit 1 - fi - cat "${probe_err}" >&2 - if ! grep -Eqi 'permission denied|operation not permitted|no permissions to create new namespace|user namespace' "${probe_err}"; then - echo "::error::bwrap failed, but not with a userns/permission denial; refusing to count it as the stock AppArmor refusal" - exit 1 - fi - echo "Stock AppArmor refused bwrap with a userns/permission denial, as expected." - - - name: Load the bwrap-scoped AppArmor profile (24.04 leg) - # Grants userns to /usr/bin/bwrap only (deploy/apparmor/cyclaw-bwrap). - # The global sysctl is left at its stock value. These are the exact - # load commands setup-guide.md gives stock 24.04 users, so CI proves - # the documented path, not a CI-only variant. - if: matrix.os == 'ubuntu-latest' - shell: bash - run: | - sudo install -m 0644 deploy/apparmor/cyclaw-bwrap /etc/apparmor.d/cyclaw-bwrap - sudo apparmor_parser -r /etc/apparmor.d/cyclaw-bwrap - - - name: Prove bwrap confinement (both Linux legs) - # 22.04 under stock policy; 24.04 under the cyclaw-bwrap profile only. - # The native gate below must also fail if this runner cannot establish - # the required confinement. - if: runner.os == 'Linux' - shell: bash - run: | - bwrap --die-with-parent --new-session --unshare-all --cap-drop ALL \ - --ro-bind / / --proc /proc --dev /dev /bin/true + run: bash scripts/ci/bwrap-apparmor-check.sh - name: macOS install-script smoke test (layout + shim + profile integration) if: runner.os == 'macOS' diff --git a/deploy/apparmor/README.md b/deploy/apparmor/README.md index e39194f0..eeb0d300 100644 --- a/deploy/apparmor/README.md +++ b/deploy/apparmor/README.md @@ -77,15 +77,16 @@ sudo apparmor_parser -R deploy/apparmor/cyclaw-gate docker compose up ``` -## `cyclaw-bwrap`: user namespaces for the agentic sandbox (Ubuntu 24.04+) +## `cyclaw-bwrap`: user namespaces for the agentic sandbox (Ubuntu 24.04) **Status: opt-in host profile, separate from `cyclaw-gate`.** Stock Ubuntu -24.04 and newer set `kernel.apparmor_restrict_unprivileged_userns=1`, so -bubblewrap cannot create its namespaces and -`scripts/verify_agentic_sandbox.py` refuses (fails closed). The -`deploy/apparmor/cyclaw-bwrap` profile lifts that block for one binary only. -Never set the global sysctl to `0` instead: that removes the restriction for -every program on the host. +24.04 sets `kernel.apparmor_restrict_unprivileged_userns=1`, so bubblewrap +cannot create its namespaces and `scripts/verify_agentic_sandbox.py` refuses +(fails closed). The `deploy/apparmor/cyclaw-bwrap` profile lifts that block +for one binary only. Never set the global sysctl to `0` instead: that removes +the restriction for every program on the host. Ubuntu 26.04 and newer already +ship an enforce-mode `bwrap` profile; do not load this one beside it (see +below). What the profile grants: @@ -113,12 +114,22 @@ To load it at boot, install it to `/etc/apparmor.d/cyclaw-bwrap` first; the **Why not Ubuntu's `bwrap-userns-restrict`.** AppArmor's extra-profiles ship a stricter option that confines `bwrap` and stacks every child under `unpriv_bwrap` (no capabilities inside the namespace). CyClaw defaults to the -one-rule profile because the stricter one is not userns-only (it also allows -`capability`, `mount`, `ptrace`, `dbus`, `network`, and `rwlkm` on `/**`, a -much larger policy that must match the host ABI — upstream declares -`abi `), its main gain is what `--cap-drop ALL` already provides -(what it adds is protection against *other* bwrap callers on the host), and CI -proves only the one-rule profile on `ubuntu-latest`. +one-rule profile on Ubuntu 24.04 because the stricter one is not userns-only +(it also allows `capability`, `mount`, `ptrace`, `dbus`, `network`, and +`rwlkm` on `/**`, a much larger policy that must match the host ABI — upstream +declares `abi `), its main gain is what `--cap-drop ALL` already +provides (what it adds is protection against *other* bwrap callers on the +host), and CI on Ubuntu 24.04 proves the one-rule profile. + +**Ubuntu 26.04 and newer.** Those images already load +`/etc/apparmor.d/bwrap-userns-restrict` (`profile bwrap /usr/bin/bwrap`) in +enforce mode while the sysctl stays 1. `/usr/bin/bwrap` passes confinement. +A copy at another path is refused with +`bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`. CI on those +versions does not load `cyclaw-bwrap`. Loading it beside the stock profile +makes `/usr/bin/bwrap` fail the same way. Do not install the 24.04 package pin +`bubblewrap=0.9.0-1ubuntu0.3` there; use the candidate in `apt-cache policy +bubblewrap`'s version table. **Residual risk.** With `cyclaw-bwrap` loaded, any local user can run `/usr/bin/bwrap` to get a user namespace — acceptable on a single-user CyClaw diff --git a/deploy/apparmor/cyclaw-bwrap b/deploy/apparmor/cyclaw-bwrap index 188caa8d..15484dde 100644 --- a/deploy/apparmor/cyclaw-bwrap +++ b/deploy/apparmor/cyclaw-bwrap @@ -1,6 +1,8 @@ # CyClaw: let bubblewrap create user namespaces on hosts that enforce -# kernel.apparmor_restrict_unprivileged_userns=1 (stock Ubuntu 24.04 and -# newer) WITHOUT turning that restriction off for the whole system. +# kernel.apparmor_restrict_unprivileged_userns=1 (stock Ubuntu 24.04) +# WITHOUT turning that restriction off for the whole system. Ubuntu 26.04 +# and newer already have an enforce-mode bwrap profile; do not load this +# one beside it (see the note below). # Never use `sysctl kernel.apparmor_restrict_unprivileged_userns=0` instead. # # Scope: attaches to exactly /usr/bin/bwrap (the apt `bubblewrap` path). No @@ -17,6 +19,11 @@ # restriction. Fine for a single-user CyClaw workstation; on shared or # multi-user hosts use Ubuntu's stricter `bwrap-userns-restrict` instead. # +# Ubuntu 26.04 and newer already load that stock profile in enforce mode +# (profile bwrap for /usr/bin/bwrap, sysctl left at 1). Do not load this +# profile beside it: both attach to /usr/bin/bwrap, and confinement then fails +# with "bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted". +# # Why not `bwrap-userns-restrict` (apparmor extra-profiles) by default: it is # not userns-only (its bwrap profile allows capability, mount, ptrace, dbus, # network and rwlkm on /**), it must match the host's AppArmor ABI, and its diff --git a/scripts/ci/bwrap-apparmor-check.sh b/scripts/ci/bwrap-apparmor-check.sh new file mode 100755 index 00000000..64e8f1d6 --- /dev/null +++ b/scripts/ci/bwrap-apparmor-check.sh @@ -0,0 +1,329 @@ +#!/usr/bin/env bash +# Version-aware bubblewrap AppArmor proof for the Linux CI legs. +# +# VERSION_ID from /etc/os-release selects the proof. The runner label does not. +# 22.04: jammy pin and stock confinement (no cyclaw-bwrap load). +# 24.04: noble pin, stock userns refusal, load deploy/apparmor/cyclaw-bwrap, +# then confinement. Same commands the ubuntu-latest leg ran before +# the 26.04 split. +# 26.04 and newer: do not load cyclaw-bwrap and do not install the noble pin. +# Require the userns sysctl to read 1, Ubuntu's bwrap profile in +# enforce mode, a non-/usr/bin copy to be refused, then the same +# confinement command on /usr/bin/bwrap. +# Anything else fails. +# +# Reads kernel.apparmor_restrict_unprivileged_userns. Never writes it. +# The version table from `apt-cache policy` is the package source of truth. +# `apt-cache show bubblewrap=` can exit 0 for a version that is +# not in that table (measured on Ubuntu 26.04 for the noble pin). + +read_os_release() { + local key="$1" + awk -F= -v key="${key}" ' + $1 == key { + val = substr($0, length(key) + 2) + gsub(/^"|"$/, "", val) + print val + exit + } + ' /etc/os-release +} + +is_2604_or_newer() { + local ver="$1" major minor + case "${ver}" in + [0-9]*.[0-9]*) ;; + *) return 1 ;; + esac + major="${ver%%.*}" + minor="${ver#*.}" + minor="${minor%%.*}" + case "${major}" in + ''|*[!0-9]*) return 1 ;; + esac + case "${minor}" in + ''|*[!0-9]*) return 1 ;; + esac + major=$((10#$major)) + minor=$((10#$minor)) + if [ "${major}" -gt 26 ]; then + return 0 + fi + if [ "${major}" -eq 26 ] && [ "${minor}" -ge 4 ]; then + return 0 + fi + return 1 +} + +policy_value() { + local key="$1" + # Read the whole policy. An early awk exit under pipefail is SIGPIPE. + awk -v key="${key}" ' + index($0, " " key ": ") == 1 && val == "" { + val = substr($0, length(key) + 5) + } + END { print val } + ' +} + +# Rows under "Version table:" only. Pin lines ("500 http://...") are not +# versions. The installed row is marked with "***". +version_table_versions() { + awk ' + /^ Version table:/ { in_table = 1; next } + in_table && /^ / { next } + in_table && /^ / { + line = $0 + sub(/^[[:space:]]+/, "", line) + sub(/^\*\*\*[[:space:]]+/, "", line) + split(line, fields, /[[:space:]]+/) + if (fields[1] ~ /^[0-9]/ && fields[1] ~ /[.:~+-]/) print fields[1] + } + ' +} + +version_in_table() { + local policy="$1" want="$2" line found=0 + # Consume every version row. grep -q would SIGPIPE the producer under pipefail. + while IFS= read -r line; do + if [ "${line}" = "${want}" ]; then + found=1 + fi + done < <(printf '%s\n' "${policy}" | version_table_versions) + [ "${found}" -eq 1 ] +} + +require_runner_temp() { + if [ -z "${RUNNER_TEMP:-}" ] || [ ! -d "${RUNNER_TEMP}" ]; then + echo "::error::RUNNER_TEMP must be an existing directory" + exit 1 + fi +} + +assert_userns_sysctl_is_one() { + local restrict + restrict="$(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || echo missing)" + echo "kernel.apparmor_restrict_unprivileged_userns=${restrict}" + if [ "${restrict}" != "1" ]; then + echo "::error::expected stock kernel.apparmor_restrict_unprivileged_userns=1, got '${restrict}'" + exit 1 + fi +} + +install_pinned_bubblewrap() { + local ver="$1" + sudo apt-get update + sudo apt-get install --yes --no-install-recommends "bubblewrap=${ver}" + bwrap --version + test "$(bwrap --version)" = "bubblewrap ${ver%%-*}" +} + +prove_confinement() { + local bin="$1" + "${bin}" --die-with-parent --new-session --unshare-all --cap-drop ALL \ + --ro-bind / / --proc /proc --dev /dev /bin/true +} + +prove_ubuntu_2204() { + install_pinned_bubblewrap '0.6.1-1ubuntu0.3' + prove_confinement bwrap +} + +prove_ubuntu_2404() { + local probe_err + install_pinned_bubblewrap '0.9.0-1ubuntu0.3' + require_runner_temp + assert_userns_sysctl_is_one + probe_err="${RUNNER_TEMP}/bwrap-stock-probe.err" + if bwrap --die-with-parent --new-session --unshare-all --cap-drop ALL \ + --ro-bind / / --proc /proc --dev /dev /bin/true 2>"${probe_err}"; then + echo "::error::bwrap succeeded under stock AppArmor; expected a refusal before the cyclaw-bwrap profile loads" + exit 1 + fi + cat "${probe_err}" >&2 + if ! grep -E -q -i 'permission denied|operation not permitted|no permissions to create new namespace|user namespace' "${probe_err}"; then + echo "::error::bwrap failed, but not with a userns/permission denial; refusing to count it as the stock AppArmor refusal" + exit 1 + fi + echo "Stock AppArmor refused bwrap with a userns/permission denial, as expected." + sudo install -m 0644 deploy/apparmor/cyclaw-bwrap /etc/apparmor.d/cyclaw-bwrap + sudo apparmor_parser -r /etc/apparmor.d/cyclaw-bwrap + prove_confinement bwrap +} + +install_distro_bubblewrap() { + local policy candidate installed upstream noble='0.9.0-1ubuntu0.3' + sudo apt-get update + policy="$(apt-cache policy bubblewrap)" + printf '%s\n' "${policy}" + candidate="$(printf '%s\n' "${policy}" | policy_value Candidate)" + if [ -z "${candidate}" ] || [ "${candidate}" = "(none)" ]; then + echo "::error::apt-cache policy has no bubblewrap candidate" + exit 1 + fi + if ! printf '%s\n' "${candidate}" | grep -E -q '^[0-9][0-9A-Za-z.+:~-]*$'; then + echo "::error::refusing bubblewrap candidate that is not a version: ${candidate}" + exit 1 + fi + if [ "${candidate}" = "${noble}" ]; then + echo "::error::26.04+ candidate is the noble pin ${noble}; refusing to install it" + exit 1 + fi + if ! version_in_table "${policy}" "${candidate}"; then + echo "::error::candidate ${candidate} is not in the apt-cache policy version table" + exit 1 + fi + sudo apt-get install --yes --no-install-recommends "bubblewrap=${candidate}" + policy="$(apt-cache policy bubblewrap)" + printf '%s\n' "${policy}" + installed="$(printf '%s\n' "${policy}" | policy_value Installed)" + if [ "${installed}" != "${candidate}" ]; then + echo "::error::installed bubblewrap ${installed} != policy candidate ${candidate}" + exit 1 + fi + if ! version_in_table "${policy}" "${installed}"; then + echo "::error::installed bubblewrap ${installed} is not in the apt-cache policy version table" + exit 1 + fi + # Single-quoted so bash does not expand the dpkg format under set -u. + local dpkg_fmt got + dpkg_fmt='${Version}' + got="$(dpkg-query -W -f="${dpkg_fmt}" bubblewrap)" + if [ "${got}" != "${candidate}" ]; then + echo "::error::dpkg bubblewrap version is not ${candidate}" + exit 1 + fi + upstream="${candidate%%-*}" + upstream="${upstream##*:}" + bwrap --version + test "$(bwrap --version)" = "bubblewrap ${upstream}" + if [ "$(command -v bwrap)" != "/usr/bin/bwrap" ]; then + echo "::error::bwrap resolved to $(command -v bwrap), expected /usr/bin/bwrap" + exit 1 + fi +} + +assert_ubuntu_bwrap_profile_enforced() { + local profiles="/sys/kernel/security/apparmor/profiles" + local profile_file="/etc/apparmor.d/bwrap-userns-restrict" + if [ ! -f "${profile_file}" ]; then + echo "::error::Ubuntu bwrap profile file ${profile_file} is missing" + exit 1 + fi + if ! grep -F -q 'profile bwrap /usr/bin/bwrap' "${profile_file}"; then + echo "::error::${profile_file} does not attach profile bwrap to /usr/bin/bwrap" + exit 1 + fi + if ! sudo test -r "${profiles}"; then + echo "::error::cannot read ${profiles}" + exit 1 + fi + if ! sudo grep -q -x 'bwrap (enforce)' "${profiles}"; then + echo "::error::Ubuntu bwrap profile is not loaded in enforce mode" + sudo cat "${profiles}" >&2 || true + exit 1 + fi + if sudo grep -q '^cyclaw-bwrap ' "${profiles}"; then + echo "::error::cyclaw-bwrap is loaded; 26.04+ must not load it" + exit 1 + fi + echo "Ubuntu profile bwrap is loaded in enforce mode." +} + +refuse_unprofiled_bwrap_copy() { + local copy probe_err + require_runner_temp + copy="${RUNNER_TEMP}/bwrap-unprofiled" + case "${copy}" in + /usr/bin|/usr/bin/*) + echo "::error::unprofiled copy path ${copy} is under /usr/bin" + exit 1 + ;; + esac + cp -a /usr/bin/bwrap "${copy}" + chmod 755 "${copy}" + if [ "$(readlink -f "${copy}")" = "$(readlink -f /usr/bin/bwrap)" ]; then + echo "::error::unprofiled copy ${copy} resolves to /usr/bin/bwrap" + exit 1 + fi + probe_err="${RUNNER_TEMP}/bwrap-unprofiled.err" + if "${copy}" --die-with-parent --new-session --unshare-all --cap-drop ALL \ + --ro-bind / / --proc /proc --dev /dev /bin/true 2>"${probe_err}"; then + echo "::error::bwrap copy at ${copy} succeeded; the userns restriction is not live" + exit 1 + fi + cat "${probe_err}" >&2 + if ! grep -F -q 'bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted' "${probe_err}"; then + echo "::error::bwrap copy at ${copy} was not refused with the measured RTM_NEWADDR denial" + exit 1 + fi + echo "Unprofiled bwrap copy at ${copy} was refused, as expected." +} + +assert_child_label() { + local label_out label_line + require_runner_temp + label_out="${RUNNER_TEMP}/bwrap-child-label.out" + /usr/bin/bwrap --die-with-parent --new-session --unshare-all --cap-drop ALL \ + --ro-bind / / --proc /proc --dev /dev \ + /bin/sh -c 'printf "child_apparmor="; tr "\0" " " < /proc/self/attr/apparmor/current; printf "\n"' \ + >"${label_out}" + cat "${label_out}" + label_line="$(sed -n 's/^child_apparmor=//p' "${label_out}")" + label_line="${label_line//$'\r'/}" + label_line="${label_line%"${label_line##*[![:space:]]}"}" + if [ "${label_line}" != "bwrap//&unpriv_bwrap (enforce)" ]; then + echo "::error::child AppArmor label '${label_line}' is not bwrap//&unpriv_bwrap (enforce)" + exit 1 + fi + echo "Child label is bwrap//&unpriv_bwrap (enforce)." +} + +prove_ubuntu_2604_or_newer() { + echo "26.04+ path: not loading cyclaw-bwrap and not installing the noble pin." + install_distro_bubblewrap + assert_userns_sysctl_is_one + assert_ubuntu_bwrap_profile_enforced + refuse_unprofiled_bwrap_copy + prove_confinement /usr/bin/bwrap + assert_child_label +} + +main() { + local os_id version_id pretty repo_root + set -euo pipefail + repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + cd "${repo_root}" + if [ ! -r /etc/os-release ]; then + echo "::error::/etc/os-release is not readable" + exit 1 + fi + os_id="$(read_os_release ID)" + version_id="$(read_os_release VERSION_ID)" + pretty="$(read_os_release PRETTY_NAME)" + printf 'ID=%s\nVERSION_ID=%s\nPRETTY_NAME=%s\n' "${os_id}" "${version_id}" "${pretty}" + if [ "${os_id}" != "ubuntu" ]; then + echo "::error::expected ID=ubuntu, got '${os_id}'" + exit 1 + fi + case "${version_id}" in + 22.04) + prove_ubuntu_2204 + ;; + 24.04) + prove_ubuntu_2404 + ;; + *) + if is_2604_or_newer "${version_id}"; then + prove_ubuntu_2604_or_newer + else + echo "::error::unsupported Ubuntu VERSION_ID='${version_id}' (expected 22.04, 24.04, or 26.04+)" + exit 1 + fi + ;; + esac +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/setup-guide.md b/setup-guide.md index 9a5c4d36..820440e9 100644 --- a/setup-guide.md +++ b/setup-guide.md @@ -173,6 +173,9 @@ sudo apt-get update sudo apt-get install --yes --no-install-recommends bubblewrap=0.6.1-1ubuntu0.3 # Ubuntu 24.04 amd64/i386: # sudo apt-get install --yes --no-install-recommends bubblewrap=0.9.0-1ubuntu0.3 +# Ubuntu 26.04 and newer: do not use the 24.04 pin. Install the candidate +# listed in `apt-cache policy bubblewrap`'s version table. On the 2026-10-13 +# GitHub ubuntu-26.04 image that candidate was 0.11.1-1ubuntu0.3. # Ubuntu ARM or Debian (inspect and use the distribution's current candidate): # apt-cache policy bubblewrap # sudo apt-get install --yes --no-install-recommends bubblewrap @@ -195,7 +198,7 @@ policy that prevents isolation must fail this check; do not disable AppArmor or kernel restrictions to make it pass. Use a host with an administrator-approved bubblewrap policy instead. -#### Stock Ubuntu 24.04 and newer: load the bubblewrap AppArmor profile +#### Stock Ubuntu 24.04: load the bubblewrap AppArmor profile Stock Ubuntu 24.04 blocks unprivileged user namespaces through AppArmor, so bubblewrap cannot create its sandbox there. `scripts/verify_agentic_sandbox.py` @@ -231,6 +234,20 @@ Ubuntu's stricter `bwrap-userns-restrict` profile instead, as described in [`deploy/apparmor/README.md`](deploy/apparmor/README.md). Never load both; they attach to the same binary. +#### Ubuntu 26.04 and newer: do not load `cyclaw-bwrap` + +Ubuntu 26.04 already loads `/etc/apparmor.d/bwrap-userns-restrict` +(`profile bwrap /usr/bin/bwrap`) in enforce mode, and +`kernel.apparmor_restrict_unprivileged_userns` stays 1. `/usr/bin/bwrap` can +create its namespaces. A copy of `bwrap` at any other path is still refused +(`bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`). + +Do not load `cyclaw-bwrap` on that host. Both profiles attach to +`/usr/bin/bwrap`, and with both loaded the confinement command fails the same +way. Do not install the Ubuntu 24.04 pin `0.9.0-1ubuntu0.3` there; it is not +in the `apt-cache policy bubblewrap` version table. Install the candidate +that table lists. Do not write the sysctl. + Do not turn off the system-wide user-namespace restriction to make the probe pass; that removes the protection for every program on the host, not just bubblewrap. diff --git a/tests/security/test_bwrap_apparmor_profile_scope.py b/tests/security/test_bwrap_apparmor_profile_scope.py index be7876d4..075bf5d6 100644 --- a/tests/security/test_bwrap_apparmor_profile_scope.py +++ b/tests/security/test_bwrap_apparmor_profile_scope.py @@ -10,8 +10,10 @@ * ``flags=(unconfined)`` is disclosed in a comment, along with the reasoning for not using Ubuntu's ``bwrap-userns-restrict``. -CI (ubuntu-latest) proves the runtime side: stock policy refuses bwrap, this -profile loads, and scripts/verify_agentic_sandbox.py passes. +CI on Ubuntu 24.04 proves the runtime side: stock policy refuses bwrap, this +profile loads, and scripts/verify_agentic_sandbox.py passes. Ubuntu 26.04 and +newer already enforce profile bwrap on /usr/bin/bwrap; CI must not load this +profile beside it (scripts/ci/bwrap-apparmor-check.sh). """ from __future__ import annotations