diff --git a/.github/workflows/README.md b/.github/workflows/README.md index d14817f002..98949bedce 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -13,6 +13,10 @@ The workflows in this directory are split so that pull requests get fast, review - `cargo shear` - `argument-comment-lint` on Linux, macOS, and Windows - `tools/argument-comment-lint` package tests when the lint or its workflow wiring changes +- `linux-sandbox-smoke.yml` is an explicit host-policy smoke test for Linux + sandboxing. It installs Ubuntu's `bubblewrap` and AppArmor profiles on the + runner, then runs `codex exec` once with the default bwrap path and once with + `use_legacy_landlock=true`. ## Post-Merge On `main` diff --git a/.github/workflows/linux-sandbox-smoke.yml b/.github/workflows/linux-sandbox-smoke.yml new file mode 100644 index 0000000000..7c6ed568b4 --- /dev/null +++ b/.github/workflows/linux-sandbox-smoke.yml @@ -0,0 +1,120 @@ +name: Linux sandbox smoke + +on: + pull_request: + paths: + - ".github/workflows/linux-sandbox-smoke.yml" + - "codex-rs/**" + push: + branches: + - main + paths: + - ".github/workflows/linux-sandbox-smoke.yml" + - "codex-rs/**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || '' }} + cancel-in-progress: ${{ github.ref_name != 'main' }} + +jobs: + codex_exec: + name: codex exec sandbox modes + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + + - name: Install host sandbox dependencies + shell: bash + run: | + set -euo pipefail + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + apparmor \ + apparmor-profiles \ + apparmor-utils \ + bubblewrap \ + libcap-dev \ + pkg-config + + - name: Check host sandbox policy + shell: bash + run: | + set -euo pipefail + + bwrap="$(command -v bwrap)" + bwrap_real="$(readlink -f "$bwrap")" + echo "bwrap=$bwrap_real" + if [[ "$bwrap_real" != "/usr/bin/bwrap" ]]; then + echo "Expected apt-installed bubblewrap at /usr/bin/bwrap." + exit 1 + fi + bwrap --version + + runner_seccomp="$(grep '^Seccomp:' /proc/self/status | tr -s '[:space:]' ' ' | cut -d' ' -f2)" + echo "runner.seccomp=$runner_seccomp" + if [[ "$runner_seccomp" != "0" ]]; then + echo "Linux workers must not run under an extra outer seccomp filter." + exit 1 + fi + + if [[ ! -r /sys/module/apparmor/parameters/enabled ]]; then + echo "AppArmor module status is unavailable on this Linux worker." + exit 1 + fi + apparmor_enabled="$(cat /sys/module/apparmor/parameters/enabled)" + echo "apparmor.enabled=$apparmor_enabled" + if [[ "$apparmor_enabled" != "Y" ]]; then + echo "AppArmor must be enabled for the Linux sandbox smoke test." + exit 1 + fi + + if ! apparmor_userns="$(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null)"; then + echo "kernel.apparmor_restrict_unprivileged_userns is unavailable on this Linux worker." + exit 1 + fi + echo "kernel.apparmor_restrict_unprivileged_userns=$apparmor_userns" + if [[ "$apparmor_userns" == "0" ]]; then + echo "AppArmor user namespace restrictions must stay enabled; do not disable them in CI." + exit 1 + fi + + if userns_clone="$(sysctl -n kernel.unprivileged_userns_clone 2>/dev/null)"; then + echo "kernel.unprivileged_userns_clone=$userns_clone" + if [[ "$userns_clone" != "1" ]]; then + echo "Linux workers must enable unprivileged user namespaces in the base image." + exit 1 + fi + fi + + profile_source="/usr/share/apparmor/extra-profiles/bwrap-userns-restrict" + profile_target="/etc/apparmor.d/bwrap-userns-restrict" + if [[ ! -r "$profile_source" ]]; then + echo "Ubuntu's bwrap-userns-restrict AppArmor profile is missing." + dpkg -L apparmor-profiles | grep bwrap || true + exit 1 + fi + + sudo ln -sf "$profile_source" "$profile_target" + sudo apparmor_parser -r "$profile_target" + sudo aa-enforce /usr/bin/bwrap + if ! sudo grep -Eq '^bwrap \(enforce\)$' /sys/kernel/security/apparmor/profiles; then + echo "Ubuntu's bwrap AppArmor profile is not loaded in enforce mode." + sudo grep bwrap /sys/kernel/security/apparmor/profiles || true + exit 1 + fi + if ! sudo grep -Eq '^bwrap//.*unpriv_bwrap \(enforce\)$' /sys/kernel/security/apparmor/profiles; then + echo "Ubuntu's unprivileged bwrap child profile is not loaded in enforce mode." + sudo grep bwrap /sys/kernel/security/apparmor/profiles || true + exit 1 + fi + + - name: Smoke test codex exec sandbox modes + working-directory: codex-rs + env: + CODEX_LINUX_SANDBOX_SMOKE: 1 + RUST_BACKTRACE: 1 + run: cargo test -p codex-exec --test all -- linux_sandbox_smoke --nocapture diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 666f3fcd06..7cda682952 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -569,7 +569,7 @@ jobs: set -euo pipefail if command -v apt-get >/dev/null 2>&1; then sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends apparmor apparmor-profiles apparmor-utils bubblewrap pkg-config libcap-dev + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev fi # Some integration tests rely on DotSlash being installed. @@ -645,114 +645,16 @@ jobs: tool: nextest version: 0.9.103 - - name: Check distro bubblewrap sandbox prerequisites (Linux) + - name: Enable unprivileged user namespaces (Linux) if: runner.os == 'Linux' - shell: bash run: | - set -euo pipefail - - bwrap="$(command -v bwrap)" - bwrap_real="$(readlink -f "$bwrap")" - echo "bwrap=$bwrap_real" - if [[ "$bwrap_real" != "/usr/bin/bwrap" ]]; then - echo "Expected apt-installed bubblewrap at /usr/bin/bwrap." - exit 1 + # Required for bubblewrap to work on Linux CI runners. + sudo sysctl -w kernel.unprivileged_userns_clone=1 + # Ubuntu 24.04+ can additionally gate unprivileged user namespaces + # behind AppArmor. + if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 fi - bwrap --version - - runner_seccomp="$(grep '^Seccomp:' /proc/self/status | tr -s '[:space:]' ' ' | cut -d' ' -f2)" - echo "runner.seccomp=$runner_seccomp" - if [[ "$runner_seccomp" != "0" ]]; then - echo "Linux workers must not run under an extra outer seccomp filter." - exit 1 - fi - - if [[ ! -r /sys/module/apparmor/parameters/enabled ]]; then - echo "AppArmor module status is unavailable on this Linux worker." - exit 1 - fi - apparmor_enabled="$(cat /sys/module/apparmor/parameters/enabled)" - echo "apparmor.enabled=$apparmor_enabled" - if [[ "$apparmor_enabled" != "Y" ]]; then - echo "AppArmor must be enabled for the distro bubblewrap smoke test." - exit 1 - fi - - if ! apparmor_userns="$(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null)"; then - echo "kernel.apparmor_restrict_unprivileged_userns is unavailable on this Linux worker." - exit 1 - fi - echo "kernel.apparmor_restrict_unprivileged_userns=$apparmor_userns" - if [[ "$apparmor_userns" == "0" ]]; then - echo "AppArmor user namespace restrictions must stay enabled; do not disable them in CI." - exit 1 - fi - - if userns_clone="$(sysctl -n kernel.unprivileged_userns_clone 2>/dev/null)"; then - echo "kernel.unprivileged_userns_clone=$userns_clone" - if [[ "$userns_clone" != "1" ]]; then - echo "Linux workers must enable unprivileged user namespaces in the base image." - exit 1 - fi - fi - - profile_source="/usr/share/apparmor/extra-profiles/bwrap-userns-restrict" - profile_target="/etc/apparmor.d/bwrap-userns-restrict" - if [[ ! -r "$profile_source" ]]; then - echo "Ubuntu's bwrap-userns-restrict AppArmor profile is missing." - dpkg -L apparmor-profiles | grep bwrap || true - exit 1 - fi - - sudo ln -sf "$profile_source" "$profile_target" - sudo apparmor_parser -r "$profile_target" - sudo aa-enforce /usr/bin/bwrap - if ! sudo grep -Eq '^bwrap \(enforce\)$' /sys/kernel/security/apparmor/profiles; then - echo "Ubuntu's bwrap AppArmor profile is not loaded in enforce mode." - sudo grep bwrap /sys/kernel/security/apparmor/profiles || true - exit 1 - fi - if ! sudo grep -Eq '^bwrap//.*unpriv_bwrap \(enforce\)$' /sys/kernel/security/apparmor/profiles; then - echo "Ubuntu's unprivileged bwrap child profile is not loaded in enforce mode." - sudo grep bwrap /sys/kernel/security/apparmor/profiles || true - exit 1 - fi - - - name: Smoke test Codex Linux sandbox through distro bubblewrap - if: runner.os == 'Linux' - shell: bash - run: | - set -euo pipefail - smoke_file=".codex-bwrap-smoke" - rm -f "$smoke_file" - trap 'rm -f "$smoke_file"' EXIT - - CODEX_HOME="${RUNNER_TEMP}/codex-bwrap-smoke-home" \ - cargo run --quiet --target ${{ matrix.target }} --profile ci-test -p codex-cli --bin codex -- \ - sandbox linux --full-auto -- bash -lc \ - ' - set -euo pipefail - - aa_profile="$(cat /proc/self/attr/current)" - echo "payload.apparmor=$aa_profile" - case "$aa_profile" in - *bwrap*unpriv_bwrap*) ;; - *) - echo "Expected payload to run under the Ubuntu unprivileged bwrap AppArmor profile." >&2 - exit 1 - ;; - esac - - seccomp_mode="$(grep "^Seccomp:" /proc/self/status | tr -s "[:space:]" " " | cut -d" " -f2)" - echo "payload.seccomp=$seccomp_mode" - if [[ "$seccomp_mode" != "2" ]]; then - echo "Expected Codex to install a seccomp filter in the sandbox payload." >&2 - exit 1 - fi - - printf ok > .codex-bwrap-smoke - test "$(cat .codex-bwrap-smoke)" = ok - ' - name: Set up remote test env (Docker) if: ${{ runner.os == 'Linux' && matrix.remote_env == 'true' }} diff --git a/codex-rs/exec/tests/suite/linux_sandbox_smoke.rs b/codex-rs/exec/tests/suite/linux_sandbox_smoke.rs new file mode 100644 index 0000000000..276c261e21 --- /dev/null +++ b/codex-rs/exec/tests/suite/linux_sandbox_smoke.rs @@ -0,0 +1,150 @@ +#![cfg(target_os = "linux")] + +use assert_cmd::prelude::*; +use core_test_support::responses; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::test_codex_exec::test_codex_exec; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; + +const SMOKE_ENV: &str = "CODEX_LINUX_SANDBOX_SMOKE"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn linux_sandbox_smoke_codex_exec_uses_distro_bwrap() -> anyhow::Result<()> { + run_codex_exec_linux_sandbox_smoke( + /*use_legacy_landlock*/ false, + "call-bwrap-smoke", + ".codex-bwrap-smoke", + BWRAP_PROBE_SCRIPT, + "smoke.ok=bwrap", + ) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn linux_sandbox_smoke_codex_exec_uses_legacy_landlock() -> anyhow::Result<()> { + run_codex_exec_linux_sandbox_smoke( + /*use_legacy_landlock*/ true, + "call-legacy-landlock-smoke", + ".codex-legacy-landlock-smoke", + LEGACY_LANDLOCK_PROBE_SCRIPT, + "smoke.ok=legacy-landlock", + ) + .await +} + +async fn run_codex_exec_linux_sandbox_smoke( + use_legacy_landlock: bool, + call_id: &str, + smoke_file: &str, + script: &str, + expected_marker: &str, +) -> anyhow::Result<()> { + if std::env::var_os(SMOKE_ENV).is_none() { + eprintln!("Skipping Linux sandbox smoke test: set {SMOKE_ENV}=1 to enable."); + return Ok(()); + } + + let test = test_codex_exec(); + let server = responses::start_mock_server().await; + let args = json!({ + "command": script, + "timeout_ms": 10_000_u64, + }); + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + ) + .await; + let results_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + let mut cmd = test.cmd_with_server(&server); + cmd.arg("--skip-git-repo-check").arg("--full-auto"); + if use_legacy_landlock { + cmd.arg("-c").arg("use_legacy_landlock=true"); + } + cmd.arg("run linux sandbox smoke").assert().success(); + + let output = results_mock + .single_request() + .function_call_output(call_id) + .get("output") + .and_then(Value::as_str) + .expect("shell command output should be a string") + .to_string(); + assert!( + output.contains(expected_marker), + "shell command output missing {expected_marker:?}: {output}" + ); + assert_eq!( + std::fs::read_to_string(test.cwd_path().join(smoke_file))?, + "ok" + ); + Ok(()) +} + +const BWRAP_PROBE_SCRIPT: &str = r#" +set -euo pipefail + +aa_profile="$(cat /proc/self/attr/current)" +echo "payload.apparmor=$aa_profile" +case "$aa_profile" in + *bwrap*unpriv_bwrap*) ;; + *) + echo "Expected payload to run under Ubuntu's unprivileged bwrap AppArmor profile." >&2 + exit 1 + ;; +esac + +seccomp_mode="$(grep '^Seccomp:' /proc/self/status | tr -s '[:space:]' ' ' | cut -d' ' -f2)" +echo "payload.seccomp=$seccomp_mode" +if [[ "$seccomp_mode" != "2" ]]; then + echo "Expected Codex to install a seccomp filter in the sandbox payload." >&2 + exit 1 +fi + +printf ok > .codex-bwrap-smoke +test "$(cat .codex-bwrap-smoke)" = ok +echo "smoke.ok=bwrap" +"#; + +const LEGACY_LANDLOCK_PROBE_SCRIPT: &str = r#" +set -euo pipefail + +aa_profile="$(cat /proc/self/attr/current)" +echo "payload.apparmor=$aa_profile" +case "$aa_profile" in + *bwrap*unpriv_bwrap*) + echo "Expected legacy Landlock smoke to avoid the bwrap AppArmor profile." >&2 + exit 1 + ;; +esac + +seccomp_mode="$(grep '^Seccomp:' /proc/self/status | tr -s '[:space:]' ' ' | cut -d' ' -f2)" +echo "payload.seccomp=$seccomp_mode" +if [[ "$seccomp_mode" != "2" ]]; then + echo "Expected Codex to install a seccomp filter in the sandbox payload." >&2 + exit 1 +fi + +printf ok > .codex-legacy-landlock-smoke +test "$(cat .codex-legacy-landlock-smoke)" = ok +echo "smoke.ok=legacy-landlock" +"#; diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index c6fa0f9fde..26b1dc3786 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -3,6 +3,7 @@ mod add_dir; mod apply_patch; mod auth_env; mod ephemeral; +mod linux_sandbox_smoke; mod mcp_required_exit; mod originator; mod output_schema;