From 70610083c81c1eb51749d4c2d115456752892fd3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 06:15:56 +0000 Subject: [PATCH] [infra] - Choose the bwrap AppArmor proof from Ubuntu VERSION_ID Ubuntu 26.04 already enforces profile bwrap on /usr/bin/bwrap. Loading cyclaw-bwrap beside it denies net_admin and the confinement command fails. 24.04 keeps the noble pin, the stock refusal, and the profile load. Co-authored-by: Chris Grady --- .github/actionlint.yaml | 7 + .github/workflows/ci.yml | 114 +++--- deploy/apparmor/README.md | 37 +- deploy/apparmor/cyclaw-bwrap | 11 +- scripts/ci/bwrap-apparmor-check.sh | 329 ++++++++++++++++++ setup-guide.md | 19 +- .../test_bwrap_apparmor_profile_scope.py | 6 +- 7 files changed, 433 insertions(+), 90 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100755 scripts/ci/bwrap-apparmor-check.sh diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..a28c4455 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,7 @@ +# actionlint 1.7.12 (pinned by ci.yml's workflow-lint job) stops its built-in +# GitHub-hosted runner list at ubuntu-24.04 and has no ignore-comment syntax. +# ubuntu-26.04 is a GitHub-hosted label for public repos (changelog 2026-09-17). +# This allowlist only makes that label lint-clean. It does not add a runner. +self-hosted-runner: + labels: + - ubuntu-26.04 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7f0b84a..1e3e2f02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -519,6 +519,26 @@ jobs: $env:USERPROFILE = $OriginalUserProfile } + # 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 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Version-aware bwrap AppArmor proof + shell: bash + run: bash scripts/ci/bwrap-apparmor-check.sh + test: name: ${{ matrix.os }} runs-on: ${{ matrix.os }} @@ -538,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. @@ -557,70 +582,15 @@ jobs: with: persist-credentials: false - - name: Install Linux verification filesystem/PID/network sandbox - 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. + - 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: | - 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