From ff04d66354dc2e5630e54cbd91d3f920e95bd8a3 Mon Sep 17 00:00:00 2001 From: GraciousGazelles <55840159+GraciousGazelles@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:05:02 +1100 Subject: [PATCH 1/4] Install runtime proof launcher from verified Linux package --- .github/workflows/sedna-branch-build.yml | 129 +++++++++++++++- .github/workflows/sedna-release.yml | 16 +- .../install_runtime_proof_launcher | 140 ++++++++++++++++++ codex-rs/runtime-proof/src/launcher.rs | 6 +- codex-rs/runtime-proof/src/launcher_tests.rs | 30 +++- docs/carry-divergence-ledger.md | 20 ++- docs/divergences/index.yaml | 3 +- docs/downstream-regression-matrix.md | 16 +- 8 files changed, 339 insertions(+), 21 deletions(-) create mode 100755 codex-rs/runtime-proof/install_runtime_proof_launcher diff --git a/.github/workflows/sedna-branch-build.yml b/.github/workflows/sedna-branch-build.yml index e38266f9299f..ab6a1e0ddf86 100644 --- a/.github/workflows/sedna-branch-build.yml +++ b/.github/workflows/sedna-branch-build.yml @@ -173,7 +173,9 @@ jobs: install -Dm 0755 "target/x86_64-unknown-linux-gnu/release/codex-code-mode-host" "${stage_dir}/codex-code-mode-host" install -Dm 0755 "target/x86_64-unknown-linux-gnu/release/codex-responses-api-proxy" "${stage_dir}/codex-responses-api-proxy" install -Dm 0755 "target/x86_64-unknown-linux-gnu/release/codex-runtime-proof-launcher" "${stage_dir}/codex-runtime-proof-launcher" + install -Dm 0755 runtime-proof/install_runtime_proof_launcher "${stage_dir}/install_runtime_proof_launcher" install -Dm 0644 runtime-proof/systemd/codex-runtime-proof-claim.service "${stage_dir}/systemd/codex-runtime-proof-claim.service" + (cd "${stage_dir}" && sha256sum codex-runtime-proof-launcher > codex-runtime-proof-launcher.sha256) file "${stage_dir}/codex" | grep -F 'x86-64' readelf -l "${stage_dir}/codex" | grep -F '/lib64/ld-linux-x86-64.so.2' @@ -191,6 +193,38 @@ jobs: file "${stage_dir}/codex-runtime-proof-launcher" | grep -F 'x86-64' readelf -l "${stage_dir}/codex-runtime-proof-launcher" | grep -F '/lib64/ld-linux-x86-64.so.2' + smoke_target=/usr/libexec/codex + if sudo test -e "${smoke_target}" || sudo test -L "${smoke_target}"; then + echo "Refusing launcher install smoke because the fixed target already exists." >&2 + exit 1 + fi + smoke_target_created=0 + cleanup_installer_smoke() { + if (( smoke_target_created == 1 )); then + sudo rm -rf -- "${smoke_target}" + fi + } + trap cleanup_installer_smoke EXIT + sudo install -d -o root -g root -m 0700 -- "${smoke_target}" + smoke_target_created=1 + printf '%s\n' 'preserved predecessor fixture' > "${RUNNER_TEMP}/runtime-proof-launcher-predecessor" + sudo install -o root -g root -m 0755 -- \ + "${RUNNER_TEMP}/runtime-proof-launcher-predecessor" \ + "${smoke_target}/codex-runtime-proof-launcher" + predecessor_sha256="$(sha256sum "${RUNNER_TEMP}/runtime-proof-launcher-predecessor" | cut -d' ' -f1)" + staged_launcher_sha256="$(sha256sum "${stage_dir}/codex-runtime-proof-launcher" | cut -d' ' -f1)" + sudo "${stage_dir}/install_runtime_proof_launcher" + installed_launcher_sha256="$(sudo sha256sum "${smoke_target}/codex-runtime-proof-launcher" | cut -d' ' -f1)" + rollback_sha256="$(sudo sha256sum "${smoke_target}/.codex-runtime-proof-launcher.rollback-${predecessor_sha256}" | cut -d' ' -f1)" + [[ "${installed_launcher_sha256}" == "${staged_launcher_sha256}" ]] + [[ "${rollback_sha256}" == "${predecessor_sha256}" ]] + [[ "$(sudo stat -c '%u:%g:%a' -- "${smoke_target}")" == '0:0:711' ]] + [[ "$(sudo stat -c '%u:%g:%a' -- "${smoke_target}/codex-runtime-proof-launcher")" == '0:0:755' ]] + [[ "$(sudo stat -c '%u:%g:%a' -- "${smoke_target}/.codex-runtime-proof-launcher.rollback-${predecessor_sha256}")" == '0:0:600' ]] + sudo rm -rf -- "${smoke_target}" + smoke_target_created=0 + trap - EXIT + tar -czf "${GITHUB_WORKSPACE}/dist/${archive_base}.tar.gz" -C "${stage_dir}" . ARCHIVE_PATH="${GITHUB_WORKSPACE}/dist/${archive_base}.tar.gz" python3 - <<'PY' > "../dist/${archive_base}.json" @@ -217,6 +251,14 @@ jobs: ) with open(launcher_path, "rb") as launcher_file: payload["runtimeProofLauncherSha256"] = hashlib.sha256(launcher_file.read()).hexdigest() + installer_path = os.path.join( + os.environ["RUNNER_TEMP"], + "sedna-branch/x86_64-unknown-linux-gnu/install_runtime_proof_launcher", + ) + with open(installer_path, "rb") as installer_file: + payload["runtimeProofLauncherInstallerSha256"] = hashlib.sha256( + installer_file.read() + ).hexdigest() with tarfile.open(os.environ["ARCHIVE_PATH"], "r:gz") as archive: members = [ member @@ -233,6 +275,41 @@ jobs: archive_digest.update(block) if archive_digest.hexdigest() != payload["runtimeProofLauncherSha256"]: raise SystemExit("preview archive runtime-proof launcher does not match its manifest") + installer_members = [ + member + for member in archive.getmembers() + if member.name + in {"./install_runtime_proof_launcher", "install_runtime_proof_launcher"} + ] + checksum_members = [ + member + for member in archive.getmembers() + if member.name + in {"./codex-runtime-proof-launcher.sha256", "codex-runtime-proof-launcher.sha256"} + ] + if ( + len(installer_members) != 1 + or not installer_members[0].isfile() + or installer_members[0].mode & 0o111 == 0 + ): + raise SystemExit("preview archive is missing the runtime-proof installer") + if len(checksum_members) != 1 or not checksum_members[0].isfile(): + raise SystemExit("preview archive is missing the runtime-proof launcher checksum") + packaged_installer = archive.extractfile(installer_members[0]) + packaged_checksum = archive.extractfile(checksum_members[0]) + if packaged_installer is None or packaged_checksum is None: + raise SystemExit("preview archive runtime-proof install inputs are unreadable") + installer_digest = hashlib.sha256() + for block in iter(lambda: packaged_installer.read(1024 * 1024), b""): + installer_digest.update(block) + if installer_digest.hexdigest() != payload["runtimeProofLauncherInstallerSha256"]: + raise SystemExit("preview archive runtime-proof installer does not match its manifest") + checksum_content = packaged_checksum.read().decode("ascii") + expected_checksum = ( + f'{payload["runtimeProofLauncherSha256"]} codex-runtime-proof-launcher\n' + ) + if checksum_content != expected_checksum: + raise SystemExit("preview archive launcher checksum does not match its manifest") json.dump(payload, sys.stdout, indent=2) sys.stdout.write("\n") PY @@ -272,7 +349,9 @@ jobs: install -Dm 0755 "target/aarch64-unknown-linux-gnu/release/codex-code-mode-host" "${stage_dir}/codex-code-mode-host" install -Dm 0755 "target/aarch64-unknown-linux-gnu/release/codex-responses-api-proxy" "${stage_dir}/codex-responses-api-proxy" install -Dm 0755 "target/aarch64-unknown-linux-gnu/release/codex-runtime-proof-launcher" "${stage_dir}/codex-runtime-proof-launcher" + install -Dm 0755 runtime-proof/install_runtime_proof_launcher "${stage_dir}/install_runtime_proof_launcher" install -Dm 0644 runtime-proof/systemd/codex-runtime-proof-claim.service "${stage_dir}/systemd/codex-runtime-proof-claim.service" + (cd "${stage_dir}" && sha256sum codex-runtime-proof-launcher > codex-runtime-proof-launcher.sha256) file "${stage_dir}/codex" | grep -F 'ARM aarch64' readelf -l "${stage_dir}/codex" | grep -F '/lib/ld-linux-aarch64.so.1' @@ -292,12 +371,13 @@ jobs: tar -C "${stage_dir}" -czf "../dist/${archive_base}.tar.gz" . - python3 - <<'PY' > "../dist/${archive_base}.json" + ARCHIVE_PATH="../dist/${archive_base}.tar.gz" python3 - <<'PY' > "../dist/${archive_base}.json" import hashlib import json import os import subprocess import sys + import tarfile payload = { "repository": os.environ["GITHUB_REPOSITORY"], @@ -316,6 +396,53 @@ jobs: ) with open(launcher_path, "rb") as launcher_file: payload["runtimeProofLauncherSha256"] = hashlib.sha256(launcher_file.read()).hexdigest() + installer_path = os.path.join( + os.environ["RUNNER_TEMP"], + "sedna-branch/aarch64-unknown-linux-gnu/install_runtime_proof_launcher", + ) + with open(installer_path, "rb") as installer_file: + payload["runtimeProofLauncherInstallerSha256"] = hashlib.sha256( + installer_file.read() + ).hexdigest() + archive_path = os.environ["ARCHIVE_PATH"] + with tarfile.open(archive_path, "r:gz") as archive: + for member_name, expected_digest in ( + ("codex-runtime-proof-launcher", payload["runtimeProofLauncherSha256"]), + ("install_runtime_proof_launcher", payload["runtimeProofLauncherInstallerSha256"]), + ): + members = [ + member + for member in archive.getmembers() + if member.name in {f"./{member_name}", member_name} + ] + if ( + len(members) != 1 + or not members[0].isfile() + or (member_name == "install_runtime_proof_launcher" and members[0].mode & 0o111 == 0) + ): + raise SystemExit(f"preview archive is missing {member_name}") + packaged_file = archive.extractfile(members[0]) + if packaged_file is None: + raise SystemExit(f"preview archive {member_name} is unreadable") + digest = hashlib.sha256() + for block in iter(lambda: packaged_file.read(1024 * 1024), b""): + digest.update(block) + if digest.hexdigest() != expected_digest: + raise SystemExit(f"preview archive {member_name} does not match its manifest") + checksum_members = [ + member + for member in archive.getmembers() + if member.name + in {"./codex-runtime-proof-launcher.sha256", "codex-runtime-proof-launcher.sha256"} + ] + if len(checksum_members) != 1 or not checksum_members[0].isfile(): + raise SystemExit("preview archive is missing the runtime-proof launcher checksum") + packaged_checksum = archive.extractfile(checksum_members[0]) + expected_checksum = ( + f'{payload["runtimeProofLauncherSha256"]} codex-runtime-proof-launcher\n' + ) + if packaged_checksum is None or packaged_checksum.read().decode("ascii") != expected_checksum: + raise SystemExit("preview archive launcher checksum does not match its manifest") json.dump(payload, sys.stdout, indent=2) sys.stdout.write("\n") PY diff --git a/.github/workflows/sedna-release.yml b/.github/workflows/sedna-release.yml index 67a239a8f856..bd9674103c2b 100644 --- a/.github/workflows/sedna-release.yml +++ b/.github/workflows/sedna-release.yml @@ -612,7 +612,9 @@ jobs: install -Dm 0755 "target/${TARGET}/release/codex" "${stage_dir}/codex" install -Dm 0755 "target/${TARGET}/release/codex-responses-api-proxy" "${stage_dir}/codex-responses-api-proxy" install -Dm 0755 "target/${TARGET}/release/codex-runtime-proof-launcher" "${stage_dir}/codex-runtime-proof-launcher" + install -Dm 0755 runtime-proof/install_runtime_proof_launcher "${stage_dir}/install_runtime_proof_launcher" install -Dm 0644 runtime-proof/systemd/codex-runtime-proof-claim.service "${stage_dir}/systemd/codex-runtime-proof-claim.service" + (cd "${stage_dir}" && sha256sum codex-runtime-proof-launcher > codex-runtime-proof-launcher.sha256) cp "target/${TARGET}/release/codex.sigstore" "${dist_dir}/${archive_base}-codex.sigstore" cp "target/${TARGET}/release/codex-responses-api-proxy.sigstore" "${dist_dir}/${archive_base}-codex-responses-api-proxy.sigstore" @@ -620,6 +622,14 @@ jobs: tar -C "${stage_dir}" -czf "${dist_dir}/${archive_base}.tar.gz" . install -m 0644 Cargo.toml Cargo.lock "${stage_dir}/" + launcher_sha256="$(sha256sum "${stage_dir}/codex-runtime-proof-launcher" | cut -d' ' -f1)" + launcher_installer_sha256="$(sha256sum "${stage_dir}/install_runtime_proof_launcher" | cut -d' ' -f1)" + archive_launcher_sha256="$(tar -xOf "${dist_dir}/${archive_base}.tar.gz" ./codex-runtime-proof-launcher | sha256sum | cut -d' ' -f1)" + archive_installer_sha256="$(tar -xOf "${dist_dir}/${archive_base}.tar.gz" ./install_runtime_proof_launcher | sha256sum | cut -d' ' -f1)" + archive_launcher_checksum="$(tar -xOf "${dist_dir}/${archive_base}.tar.gz" ./codex-runtime-proof-launcher.sha256)" + [[ "${archive_launcher_sha256}" == "${launcher_sha256}" ]] + [[ "${archive_installer_sha256}" == "${launcher_installer_sha256}" ]] + [[ "${archive_launcher_checksum}" == "${launcher_sha256} codex-runtime-proof-launcher" ]] cat > "${dist_dir}/${METADATA_TEXT_NAME}" < "${dist_dir}/${METADATA_JSON_NAME}" + LAUNCHER_SHA256="${launcher_sha256}" LAUNCHER_INSTALLER_SHA256="${launcher_installer_sha256}" python3 - <<'PY' > "${dist_dir}/${METADATA_JSON_NAME}" import json import os @@ -667,6 +679,8 @@ jobs: "workflow_sha": os.environ["WORKFLOW_SHA"], "repository": os.environ["REPOSITORY"], "target": os.environ["TARGET"], + "runtime_proof_launcher_sha256": os.environ["LAUNCHER_SHA256"], + "runtime_proof_launcher_installer_sha256": os.environ["LAUNCHER_INSTALLER_SHA256"], }, fp=os.sys.stdout, indent=2, diff --git a/codex-rs/runtime-proof/install_runtime_proof_launcher b/codex-rs/runtime-proof/install_runtime_proof_launcher new file mode 100755 index 000000000000..aa1e63808c76 --- /dev/null +++ b/codex-rs/runtime-proof/install_runtime_proof_launcher @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +readonly launcher_name=codex-runtime-proof-launcher +readonly target_dir=/usr/libexec/codex +readonly target_path="${target_dir}/${launcher_name}" + +temporary_path= +rollback_temporary_path= +created_target_dir=0 +parent_mode_before= +parent_mode_changed=0 +replacement_complete=0 + +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + if (( status != 0 && replacement_complete == 0 )); then + if [[ -f "${target_path}" && ! -L "${target_path}" ]]; then + installed_digest=$(sha256sum < "${target_path}") + installed_digest=${installed_digest%% *} + [[ "${installed_digest}" == "${expected_digest:-}" ]] && replacement_complete=1 + fi + fi + if (( status != 0 && replacement_complete == 0 )); then + [[ -z "${temporary_path}" ]] || rm -f -- "${temporary_path}" + [[ -z "${rollback_temporary_path}" ]] || rm -f -- "${rollback_temporary_path}" + if (( parent_mode_changed == 1 )) && [[ -d "${target_dir}" && ! -L "${target_dir}" ]]; then + chmod "${parent_mode_before}" -- "${target_dir}" 2>/dev/null || true + fi + if (( created_target_dir == 1 )); then + rmdir -- "${target_dir}" 2>/dev/null || true + fi + fi + exit "${status}" +} +trap cleanup EXIT +trap 'exit 130' HUP INT TERM + +fail() { + printf '%s\n' "$1" >&2 + exit 1 +} + +if (( EUID != 0 )); then + fail 'Run the fixed runtime-proof launcher installer with root privileges.' +fi +if (( $# != 0 )); then + fail 'The fixed runtime-proof launcher installer accepts no arguments.' +fi + +script_path=${BASH_SOURCE[0]} +[[ ! -L "${script_path}" && -f "${script_path}" ]] || fail 'The package installer must be a regular file.' +package_dir=$(cd -- "$(dirname -- "${script_path}")" && pwd -P) +launcher_path="${package_dir}/${launcher_name}" +checksum_path="${package_dir}/${launcher_name}.sha256" +[[ -f "${launcher_path}" && ! -L "${launcher_path}" && -x "${launcher_path}" ]] || fail 'The packaged launcher is not a regular executable.' +[[ -f "${checksum_path}" && ! -L "${checksum_path}" ]] || fail 'The package launcher checksum is missing.' +checksum_size=$(stat -c '%s' -- "${checksum_path}") +(( checksum_size <= 128 )) || fail 'The package launcher checksum is too large.' + +checksum_line=$(cat -- "${checksum_path}") +[[ "${checksum_line}" =~ ^([0-9a-f]{64})[[:space:]][[:space:]]${launcher_name}$ ]] || fail 'The package launcher checksum format is invalid.' +expected_digest=${BASH_REMATCH[1]} + +for trusted_parent in /usr /usr/libexec; do + [[ -d "${trusted_parent}" && ! -L "${trusted_parent}" ]] || fail 'A fixed launcher parent is missing or unsafe.' + IFS=: read -r owner group mode < <(stat -c '%u:%g:%a' -- "${trusted_parent}") + [[ "${owner}" == 0 && "${group}" == 0 ]] || fail 'A fixed launcher parent is not root-owned.' + mode_value=$((8#${mode})) + (( (mode_value & 07022) == 0 && (mode_value & 0001) != 0 )) || fail 'A fixed launcher parent is writable or not traversable.' +done + +if [[ -e "${target_dir}" || -L "${target_dir}" ]]; then + [[ -d "${target_dir}" && ! -L "${target_dir}" ]] || fail 'The fixed launcher directory is not a regular directory.' + IFS=: read -r owner group parent_mode_before < <(stat -c '%u:%g:%a' -- "${target_dir}") + [[ "${owner}" == 0 && "${group}" == 0 ]] || fail 'The fixed launcher directory is not root-owned.' + mode_value=$((8#${parent_mode_before})) + (( (mode_value & 07022) == 0 )) || fail 'The fixed launcher directory is writable or has special permission bits.' +else + install -d -o root -g root -m 0711 -- "${target_dir}" + created_target_dir=1 +fi + +previous_digest= +if [[ -e "${target_path}" || -L "${target_path}" ]]; then + [[ -f "${target_path}" && ! -L "${target_path}" ]] || fail 'The installed launcher is not a regular file.' + IFS=: read -r owner group target_mode target_size < <(stat -c '%u:%g:%a:%s' -- "${target_path}") + [[ "${owner}" == 0 && "${group}" == 0 && "${target_size}" -gt 0 && "${target_size}" -le 2147483648 ]] || fail 'The installed launcher is outside its fixed file contract.' + mode_value=$((8#${target_mode})) + (( (mode_value & 0022) == 0 && (mode_value & 0111) != 0 && (mode_value & 06000) == 0 )) || fail 'The installed launcher permissions are unsafe.' + previous_digest=$(sha256sum < "${target_path}") + previous_digest=${previous_digest%% *} +fi + +if [[ "${previous_digest}" != "${expected_digest}" ]]; then + temporary_path=$(mktemp "${target_dir}/.launcher-install.XXXXXX") + install -o root -g root -m 0700 -- "${launcher_path}" "${temporary_path}" + staged_digest=$(sha256sum < "${temporary_path}") + staged_digest=${staged_digest%% *} + [[ "${staged_digest}" == "${expected_digest}" ]] || fail 'The staged launcher does not match its package checksum.' + chmod 0755 -- "${temporary_path}" + + if [[ -n "${previous_digest}" ]]; then + rollback_path="${target_dir}/.codex-runtime-proof-launcher.rollback-${previous_digest}" + if [[ -e "${rollback_path}" || -L "${rollback_path}" ]]; then + [[ -f "${rollback_path}" && ! -L "${rollback_path}" ]] || fail 'The matching rollback path is unsafe.' + IFS=: read -r owner group rollback_mode < <(stat -c '%u:%g:%a' -- "${rollback_path}") + [[ "${owner}" == 0 && "${group}" == 0 && "${rollback_mode}" == 600 ]] || fail 'The matching rollback image is outside root-only custody.' + rollback_digest=$(sha256sum < "${rollback_path}") + [[ "${rollback_digest%% *}" == "${previous_digest}" ]] || fail 'The matching rollback image digest does not match.' + else + rollback_temporary_path=$(mktemp "${target_dir}/.launcher-rollback.XXXXXX") + install -o root -g root -m 0600 -- "${target_path}" "${rollback_temporary_path}" + rollback_digest=$(sha256sum < "${rollback_temporary_path}") + [[ "${rollback_digest%% *}" == "${previous_digest}" ]] || fail 'The preserved rollback image does not match the installed predecessor.' + rollback_path="${target_dir}/.codex-runtime-proof-launcher.rollback-${previous_digest}" + mv -T -- "${rollback_temporary_path}" "${rollback_path}" + rollback_temporary_path= + fi + fi + + if [[ -n "${parent_mode_before}" ]]; then + chmod 0711 -- "${target_dir}" + parent_mode_changed=1 + fi + mv -T -- "${temporary_path}" "${target_path}" + temporary_path= + replacement_complete=1 +else + if [[ -n "${parent_mode_before}" && "${parent_mode_before}" != 711 ]]; then + chmod 0711 -- "${target_dir}" + parent_mode_changed=1 + fi + replacement_complete=1 +fi + +sync -f "${target_dir}" +printf '%s\n' 'The fixed runtime-proof launcher package is installed. No service was started or reloaded.' diff --git a/codex-rs/runtime-proof/src/launcher.rs b/codex-rs/runtime-proof/src/launcher.rs index b9871d573547..4332a5a1d407 100644 --- a/codex-rs/runtime-proof/src/launcher.rs +++ b/codex-rs/runtime-proof/src/launcher.rs @@ -391,7 +391,11 @@ fn require_controlled_parent_directories( .context("installed fixed launcher has no parent directory")?; loop { let metadata = fs::symlink_metadata(path)?; - if !metadata.is_dir() || metadata.uid() != expected_uid || metadata.mode() & 0o022 != 0 { + if !metadata.is_dir() + || metadata.uid() != expected_uid + || metadata.mode() & 0o022 != 0 + || metadata.mode() & 0o001 == 0 + { bail!("installed fixed launcher path is not root controlled"); } if path == trust_boundary { diff --git a/codex-rs/runtime-proof/src/launcher_tests.rs b/codex-rs/runtime-proof/src/launcher_tests.rs index e8b88ca7e846..8a58df9f1e6e 100644 --- a/codex-rs/runtime-proof/src/launcher_tests.rs +++ b/codex-rs/runtime-proof/src/launcher_tests.rs @@ -129,7 +129,7 @@ fn installed_launcher_parent_validation_rejects_a_group_writable_parent() { let launcher_parent = launcher_path .parent() .expect("launcher fixture has a parent directory"); - fs::set_permissions(launcher_parent, fs::Permissions::from_mode(0o770)) + fs::set_permissions(launcher_parent, fs::Permissions::from_mode(0o771)) .expect("make launcher fixture parent group writable"); assert!( @@ -140,19 +140,39 @@ fn installed_launcher_parent_validation_rejects_a_group_writable_parent() { fs::remove_dir_all(trust_boundary).expect("remove unsafe launcher fixture"); } +#[test] +fn installed_launcher_parent_validation_rejects_a_parent_without_invoker_traverse() { + let (trust_boundary, launcher_path) = launcher_path_fixture(); + let expected_uid = fs::metadata(&trust_boundary) + .expect("read trusted fixture directory metadata") + .uid(); + let launcher_parent = launcher_path + .parent() + .expect("launcher fixture has a parent directory"); + fs::set_permissions(launcher_parent, fs::Permissions::from_mode(0o700)) + .expect("remove invoking-user traversal from launcher fixture parent"); + + assert!( + require_controlled_parent_directories(&launcher_path, &trust_boundary, expected_uid) + .is_err() + ); + + fs::remove_dir_all(trust_boundary).expect("remove unsafe launcher fixture"); +} + fn launcher_path_fixture() -> (PathBuf, PathBuf) { let trust_boundary = std::env::temp_dir().join(format!( "codex-runtime-proof-launcher-path-{}", Uuid::new_v4() )); fs::create_dir(&trust_boundary).expect("create trusted launcher fixture directory"); - fs::set_permissions(&trust_boundary, fs::Permissions::from_mode(0o700)) - .expect("restrict trusted launcher fixture directory"); + fs::set_permissions(&trust_boundary, fs::Permissions::from_mode(0o711)) + .expect("make trusted launcher fixture traversable without listing or writes"); let launcher_parent = trust_boundary.join("libexec"); fs::create_dir(&launcher_parent).expect("create launcher parent fixture"); - fs::set_permissions(&launcher_parent, fs::Permissions::from_mode(0o700)) - .expect("restrict launcher parent fixture"); + fs::set_permissions(&launcher_parent, fs::Permissions::from_mode(0o711)) + .expect("make launcher parent fixture traversable without listing or writes"); let launcher_path = launcher_parent.join("codex-runtime-proof-launcher"); fs::write(&launcher_path, b"fixture").expect("create regular launcher file fixture"); diff --git a/docs/carry-divergence-ledger.md b/docs/carry-divergence-ledger.md index c216d204678a..a0bfd9a1a2af 100644 --- a/docs/carry-divergence-ledger.md +++ b/docs/carry-divergence-ledger.md @@ -3776,7 +3776,7 @@ archive and binding and verifies them against the target's pinned checksum manifest. Use the host-only artifact only with companion binaries built from the same source commit. -## Native per-execution proof for a protected MCP claim +## Native per-execution proof for protected MCP and HTTP claims The Linux CLI accepts an optional protected bootstrap through one inherited anonymous descriptor before dispatching arguments or starting asynchronous @@ -3949,12 +3949,18 @@ codes or closed SDK error categories; internal error strings, OAuth response details, and callback input remain suppressed. The installed-image guard checks the executable's parent directories through `/` separately from the executable's own regular-file, mode, owner, and inode -checks; regressions cover a valid executable in trusted parents and a writable -parent rejection. - -The unit template and binary are packaged for Linux x86_64 and Arm64; packaging -does not create the host policy, credentials, service account, issuer -appointment, service installation, or live execution authority. +checks; regressions cover a valid executable in traversable trusted parents, +and reject both writable and non-traversable parents. + +Linux release and branch-preview archives package the fixed installer with the +launcher and its checksum for x86_64 and Arm64. It verifies the staged launcher +bytes against that package checksum, changes only `/usr/libexec/codex`, and +atomically replaces the fixed root-owned executable after preserving the exact +predecessor as a root-only digest-named rollback image. The directory is +root-owned mode `0711` so invoking users can traverse the path without listing +or writing it. Installation never starts or reloads the unit. Packaging does +not create the host policy, credentials, service account, issuer appointment, +or live execution authority. Curated plugin ZIP extraction applies Unix permissions only after masking to ordinary `0o777` bits. This keeps executable plugin files usable while making diff --git a/docs/divergences/index.yaml b/docs/divergences/index.yaml index 94c12b7df79d..368b67974bbe 100644 --- a/docs/divergences/index.yaml +++ b/docs/divergences/index.yaml @@ -3773,7 +3773,7 @@ ".github/workflows/sedna-branch-build.yml", ".github/workflows/sedna-release.yml" ], - "behavior": "A fixed root-owned Linux one-shot launcher selects one closed MCP or HTTP claim target. Its MCP target starts the measured codex-dev ELF, drops the child to its configured unprivileged UID/GID, verifies the executed file object and child identity before opening systemd LoadCredential inputs, and sends the existing bounded root-peer proof/authentication frames. Its HTTP target measures the running launcher ELF itself before reading a separately provisioned API bearer, then sends only the fixed POST /v0/work-items/claim operation. The root-only provision-api-bearer mode runs only through the installed root-owned launcher under interactive sudo. SUDO_UID selects only the unprivileged API-profile child and optional browser process; the OAuth token and later root policy, not caller metadata, own the subject binding. Its exact --no-browser form uses the same authorization-code and PKCE flow without a browser opener or local callback listener, displaying the generated authorization URL and accepting a complete callback URL only on the invoking TTY with input echo suppressed and prior terminal settings restored on success, error, or handled cancellation signals. It binds callback scheme, loopback host, port, registered path, unique state and unambiguous parameters before the same PKCE exchange and token checks. OAuth exchange failures expose only allowlisted HTTP status/OAuth codes or a closed SDK error category, never provider text. Both forms use the fixed loopback callback and API issuer/resource/client/ops:write scope and write no entry to the persistent MCP credential store. The invoking user's direct OS keyring stores a separate versioned API refresh profile bound to the fixed issuer, client, resource, scope, and canonical subject. Refresh uses the existing lock, persists rotation before releasing only a short-lived access token over a bounded root-created private channel, and fails closed on keyring write failure. It never stores the MCP profile or falls back to the Codex credential file; logout deletes only this API entry. Before the root writes the volatile bearer it checks the fixed issuer, ops-api audience membership, exact ops:write scope, canonical UUID subject, bounded expiry, and any already-installed HTTP policy principal; provisioning may precede policy creation. The configured client’s ops:write scope contributes its existing MCP audiences alongside the client-specific ops-api audience; no shared mapper or scope is changed. The actual HTTP launcher checks the token subject against the root-owned policy before signing or sending. It atomically writes a versioned generation envelope only to /run/credstore/ops-api-bearer, which the fixed unit loads through source-less LoadCredential=ops-api-bearer. After systemd copies it into the private credential directory, the launcher removes only the exact loaded envelope under the same lock as replacement, preserving a replacement even if it contains the same access token. The unit remains conditioned on the separate root claim policy, which this mode neither creates nor activates. The mode accepts no caller-selected URL, client, scope, path or command, and does not create policy or execute a claim. Provisioning failures expose a closed phase and allowlisted HTTP or typed SDK category while suppressing internal error details. Both claim targets use the same root-owned issuer custody and exact operation-bound certificate/proof wire format; the API branch pins agent-ops-api-claim-client, the canonical API recipient, request ID, claim precondition and final-body digest, omits MCP-only fields, and uses one bounded direct request with redirects, retries and request logging disabled. A missing or mismatched API bearer fails closed; API-server signature and caller authentication remain authoritative, and transport uncertainty is never replayed. The MCP target and Linux CLI retain their existing MCP claim behavior. The CLI validates immutable configuration and the effective built-in ChatGPT recipient, imports finite ChatGPT credentials into ephemeral storage, and retains process-scoped signing and selected MCP bearer capabilities. Each native Session contributes a fresh execution nonce, including delegated Sessions that share the MCP manager; a short-lived signature covers the exact final MCP claim request. Certificate SHA-256 claims use the verifier's bare lowercase hexadecimal encoding, while operation digests retain their sha256:-prefixed encoding. Resolved provider configuration is compared with the canonical built-in provider before auth resolution and again before HTTP or websocket transport construction, including provider overrides. A protected MCP target accepts only the reserved local environment and always uses the process-local HTTP client; a remote environment descriptor remains rejected even when paired with the local ID. Until the credential-bearing managed-cloud configuration consumer is admitted to the protected recipient contract, eligible business, enterprise and education protected accounts fail closed before credential import and cloud-loader startup; ordinary Codex cloud configuration behavior is unchanged. Synthetic fixtures use only fixed public fake credentials and loopback recipients. Ordinary invocations remain unconfigured when no protected bootstrap is present.", + "behavior": "A fixed root-owned Linux one-shot launcher selects one closed MCP or HTTP claim target. Its MCP target starts the measured codex-dev ELF, drops the child to its configured unprivileged UID/GID, verifies the executed file object and child identity before opening systemd LoadCredential inputs, and sends the existing bounded root-peer proof/authentication frames. Its HTTP target measures the running launcher ELF itself before reading a separately provisioned API bearer, then sends only the fixed POST /v0/work-items/claim operation. The root-only provision-api-bearer mode runs only through the installed root-owned launcher under interactive sudo. SUDO_UID selects only the unprivileged API-profile child and optional browser process; the OAuth token and later root policy, not caller metadata, own the subject binding. Its exact --no-browser form uses the same authorization-code and PKCE flow without a browser opener or local callback listener, displaying the generated authorization URL and accepting a complete callback URL only on the invoking TTY with input echo suppressed and prior terminal settings restored on success, error, or handled cancellation signals. It binds callback scheme, loopback host, port, registered path, unique state and unambiguous parameters before the same PKCE exchange and token checks. OAuth exchange failures expose only allowlisted HTTP status/OAuth codes or a closed SDK error category, never provider text. Both forms use the fixed loopback callback and API issuer/resource/client/ops:write scope and write no entry to the persistent MCP credential store. The invoking user's direct OS keyring stores a separate versioned API refresh profile bound to the fixed issuer, client, resource, scope, and canonical subject. Refresh uses the existing lock, persists rotation before releasing only a short-lived access token over a bounded root-created private channel, and fails closed on keyring write failure. It never stores the MCP profile or falls back to the Codex credential file; logout deletes only this API entry. Before the root writes the volatile bearer it checks the fixed issuer, ops-api audience membership, exact ops:write scope, canonical UUID subject, bounded expiry, and any already-installed HTTP policy principal; provisioning may precede policy creation. The configured client’s ops:write scope contributes its existing MCP audiences alongside the client-specific ops-api audience; no shared mapper or scope is changed. The actual HTTP launcher checks the token subject against the root-owned policy before signing or sending. It atomically writes a versioned generation envelope only to /run/credstore/ops-api-bearer, which the fixed unit loads through source-less LoadCredential=ops-api-bearer. After systemd copies it into the private credential directory, the launcher removes only the exact loaded envelope under the same lock as replacement, preserving a replacement even if it contains the same access token. The unit remains conditioned on the separate root claim policy, which this mode neither creates nor activates. The mode accepts no caller-selected URL, client, scope, path or command, and does not create policy or execute a claim. Provisioning failures expose a closed phase and allowlisted HTTP or typed SDK category while suppressing internal error details. Both claim targets use the same root-owned issuer custody and exact operation-bound certificate/proof wire format; the API branch pins agent-ops-api-claim-client, the canonical API recipient, request ID, claim precondition and final-body digest, omits MCP-only fields, and uses one bounded direct request with redirects, retries and request logging disabled. A missing or mismatched API bearer fails closed; API-server signature and caller authentication remain authoritative, and transport uncertainty is never replayed. The MCP target and Linux CLI retain their existing MCP claim behavior. The CLI validates immutable configuration and the effective built-in ChatGPT recipient, imports finite ChatGPT credentials into ephemeral storage, and retains process-scoped signing and selected MCP bearer capabilities. Each native Session contributes a fresh execution nonce, including delegated Sessions that share the MCP manager; a short-lived signature covers the exact final MCP claim request. Certificate SHA-256 claims use the verifier's bare lowercase hexadecimal encoding, while operation digests retain their sha256:-prefixed encoding. Resolved provider configuration is compared with the canonical built-in provider before auth resolution and again before HTTP or websocket transport construction, including provider overrides. A protected MCP target accepts only the reserved local environment and always uses the process-local HTTP client; a remote environment descriptor remains rejected even when paired with the local ID. Until the credential-bearing managed-cloud configuration consumer is admitted to the protected recipient contract, eligible business, enterprise and education protected accounts fail closed before credential import and cloud-loader startup; ordinary Codex cloud configuration behavior is unchanged. Synthetic fixtures use only fixed public fake credentials and loopback recipients. Ordinary invocations remain unconfigured when no protected bootstrap is present. Linux release and branch-preview archives carry a fixed installer with a launcher checksum; it sets only /usr/libexec/codex to root-owned mode 0711 and atomically replaces the fixed root-owned executable after preserving an exact root-only rollback image, without starting or reloading the unit.", "upstream_equivalent": false, "introduced_in": {"carry_commit": null, "upstream_commit": null}, "guardrail_lane": "core-runtime-surface-smoke; codex.mcp-safety-targeted; codex.downstream-docs-check; sedna-branch-build; sedna-release", @@ -3792,6 +3792,7 @@ "api_bearer_provisioning_failure_exposes_only_a_fixed_stage", "installed_launcher_parent_validation_accepts_a_file_in_trusted_directories", "installed_launcher_parent_validation_rejects_a_group_writable_parent", + "installed_launcher_parent_validation_rejects_a_parent_without_invoker_traverse", "api_bearer_credential_envelope_has_a_fresh_cleanup_identity", "sudo_invoker_uid_must_be_non_root_decimal", "cleanup_removes_only_the_exact_loaded_credential_generation", diff --git a/docs/downstream-regression-matrix.md b/docs/downstream-regression-matrix.md index 4ef06f51b78d..260c3587961f 100644 --- a/docs/downstream-regression-matrix.md +++ b/docs/downstream-regression-matrix.md @@ -1188,11 +1188,17 @@ to the same raw-config admission used by the protected child, verify a certificate signature with the configured issuer pin, check the exact length-prefixed bootstrap packet, bound fixed-prompt delivery when the child pipe is backpressured, and reject unsafe project IDs or noncanonical issuer -pins. Linux release and branch-preview packaging must -include the launcher and its systemd unit template in x86_64 and Arm64 archives, -verify the architecture and binary digest, and keep the template inert; source -or package proof does not establish policy/credential provisioning, service -installation, issuer appointment, or first use. Its runtime-home regression +pins. Linux release and branch-preview packaging must include the launcher, +fixed installer, launcher checksum and its systemd unit template in x86_64 and +Arm64 archives, verify the architecture and launcher/installer digests, and +keep the template inert. The installer may change only `/usr/libexec/codex`: it +enforces root ownership and mode `0711`, verifies the staged launcher bytes, +preserves the exact predecessor as a root-only digest-named rollback image, and +uses atomic replacement without starting or reloading the unit. The +installed-image regression requires invoking-user traversal through trusted +parents and still rejects writable parents. Source or package proof does not +establish policy/credential provisioning, service installation, issuer +appointment, or first use. Its runtime-home regression checks that the child can write its private state and read its installation ID without changing generated configuration or adding CODEX_HOME settings. From eae1b994c59f8abfa1951fdbdd0f8c74494ee690 Mon Sep 17 00:00:00 2001 From: GraciousGazelles <55840159+GraciousGazelles@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:13:21 +1100 Subject: [PATCH 2/4] Require protected staging for launcher installation --- .github/workflows/sedna-branch-build.yml | 47 ++++++++++++++++++- .../install_runtime_proof_launcher | 26 ++++++++++ docs/carry-divergence-ledger.md | 21 ++++++--- docs/downstream-regression-matrix.md | 19 +++++--- 4 files changed, 98 insertions(+), 15 deletions(-) diff --git a/.github/workflows/sedna-branch-build.yml b/.github/workflows/sedna-branch-build.yml index ab6a1e0ddf86..01c9860e0ff9 100644 --- a/.github/workflows/sedna-branch-build.yml +++ b/.github/workflows/sedna-branch-build.yml @@ -199,21 +199,60 @@ jobs: exit 1 fi smoke_target_created=0 + smoke_package_dir= + smoke_unsafe_package_dir= cleanup_installer_smoke() { if (( smoke_target_created == 1 )); then sudo rm -rf -- "${smoke_target}" fi + if [[ -n "${smoke_package_dir}" ]]; then + sudo rm -rf -- "${smoke_package_dir}" + fi + if [[ -n "${smoke_unsafe_package_dir}" ]]; then + sudo rm -rf -- "${smoke_unsafe_package_dir}" + fi } trap cleanup_installer_smoke EXIT sudo install -d -o root -g root -m 0700 -- "${smoke_target}" smoke_target_created=1 + if sudo "${stage_dir}/install_runtime_proof_launcher" >/dev/null 2>&1; then + echo "The runtime-proof installer accepted user-owned package staging." >&2 + exit 1 + fi + smoke_unsafe_package_dir="${stage_dir}/root-owned-installer-stage" + sudo install -d -o root -g root -m 0700 -- "${smoke_unsafe_package_dir}" + sudo install -o root -g root -m 0755 -- \ + "${stage_dir}/install_runtime_proof_launcher" \ + "${smoke_unsafe_package_dir}/install_runtime_proof_launcher" + sudo install -o root -g root -m 0755 -- \ + "${stage_dir}/codex-runtime-proof-launcher" \ + "${smoke_unsafe_package_dir}/codex-runtime-proof-launcher" + sudo install -o root -g root -m 0644 -- \ + "${stage_dir}/codex-runtime-proof-launcher.sha256" \ + "${smoke_unsafe_package_dir}/codex-runtime-proof-launcher.sha256" + if sudo "${smoke_unsafe_package_dir}/install_runtime_proof_launcher" >/dev/null 2>&1; then + echo "The runtime-proof installer accepted a root-owned stage under a user-controlled parent." >&2 + exit 1 + fi + sudo rm -rf -- "${smoke_unsafe_package_dir}" + smoke_unsafe_package_dir= + smoke_package_dir="$(sudo mktemp -d /run/codex-runtime-proof-installer.XXXXXX)" + sudo install -o root -g root -m 0755 -- \ + "${stage_dir}/install_runtime_proof_launcher" \ + "${smoke_package_dir}/install_runtime_proof_launcher" + sudo install -o root -g root -m 0755 -- \ + "${stage_dir}/codex-runtime-proof-launcher" \ + "${smoke_package_dir}/codex-runtime-proof-launcher" + sudo install -o root -g root -m 0644 -- \ + "${stage_dir}/codex-runtime-proof-launcher.sha256" \ + "${smoke_package_dir}/codex-runtime-proof-launcher.sha256" printf '%s\n' 'preserved predecessor fixture' > "${RUNNER_TEMP}/runtime-proof-launcher-predecessor" sudo install -o root -g root -m 0755 -- \ "${RUNNER_TEMP}/runtime-proof-launcher-predecessor" \ "${smoke_target}/codex-runtime-proof-launcher" predecessor_sha256="$(sha256sum "${RUNNER_TEMP}/runtime-proof-launcher-predecessor" | cut -d' ' -f1)" staged_launcher_sha256="$(sha256sum "${stage_dir}/codex-runtime-proof-launcher" | cut -d' ' -f1)" - sudo "${stage_dir}/install_runtime_proof_launcher" + sudo "${smoke_package_dir}/install_runtime_proof_launcher" installed_launcher_sha256="$(sudo sha256sum "${smoke_target}/codex-runtime-proof-launcher" | cut -d' ' -f1)" rollback_sha256="$(sudo sha256sum "${smoke_target}/.codex-runtime-proof-launcher.rollback-${predecessor_sha256}" | cut -d' ' -f1)" [[ "${installed_launcher_sha256}" == "${staged_launcher_sha256}" ]] @@ -223,6 +262,12 @@ jobs: [[ "$(sudo stat -c '%u:%g:%a' -- "${smoke_target}/.codex-runtime-proof-launcher.rollback-${predecessor_sha256}")" == '0:0:600' ]] sudo rm -rf -- "${smoke_target}" smoke_target_created=0 + sudo rm -rf -- "${smoke_package_dir}" + smoke_package_dir= + if [[ -n "${smoke_unsafe_package_dir}" ]]; then + sudo rm -rf -- "${smoke_unsafe_package_dir}" + smoke_unsafe_package_dir= + fi trap - EXIT tar -czf "${GITHUB_WORKSPACE}/dist/${archive_base}.tar.gz" -C "${stage_dir}" . diff --git a/codex-rs/runtime-proof/install_runtime_proof_launcher b/codex-rs/runtime-proof/install_runtime_proof_launcher index aa1e63808c76..e875fd6b2be8 100755 --- a/codex-rs/runtime-proof/install_runtime_proof_launcher +++ b/codex-rs/runtime-proof/install_runtime_proof_launcher @@ -53,10 +53,36 @@ fi script_path=${BASH_SOURCE[0]} [[ ! -L "${script_path}" && -f "${script_path}" ]] || fail 'The package installer must be a regular file.' package_dir=$(cd -- "$(dirname -- "${script_path}")" && pwd -P) +package_dir_parent=$(dirname -- "${package_dir}") +package_dir_name=$(basename -- "${package_dir}") +if [[ "${package_dir_parent}" != /run || "${package_dir_name}" != codex-runtime-proof-installer.* ]]; then + fail 'The verified package is outside the fixed root-owned staging path.' +fi +[[ -d /run && ! -L /run ]] || fail 'The fixed package staging parent is missing or unsafe.' +IFS=: read -r owner group staging_parent_mode < <(stat -c '%u:%g:%a' -- /run) +[[ "${owner}" == 0 && "${group}" == 0 ]] || fail 'The fixed package staging parent is not root-owned.' +mode_value=$((8#${staging_parent_mode})) +(( (mode_value & 07022) == 0 && (mode_value & 0001) != 0 )) || fail 'The fixed package staging parent is writable or not traversable.' +IFS=: read -r owner group package_mode < <(stat -c '%u:%g:%a' -- "${package_dir}") +[[ "${owner}" == 0 && "${group}" == 0 ]] || fail 'The verified package staging directory is not root-owned.' +mode_value=$((8#${package_mode})) +(( (mode_value & 07022) == 0 )) || fail 'The verified package staging directory is writable or has special permission bits.' + +IFS=: read -r owner group installer_mode < <(stat -c '%u:%g:%a' -- "${script_path}") +[[ "${owner}" == 0 && "${group}" == 0 ]] || fail 'The package installer is not root-owned.' +mode_value=$((8#${installer_mode})) +(( (mode_value & 07022) == 0 && (mode_value & 0111) != 0 )) || fail 'The package installer permissions are unsafe.' + launcher_path="${package_dir}/${launcher_name}" checksum_path="${package_dir}/${launcher_name}.sha256" [[ -f "${launcher_path}" && ! -L "${launcher_path}" && -x "${launcher_path}" ]] || fail 'The packaged launcher is not a regular executable.' [[ -f "${checksum_path}" && ! -L "${checksum_path}" ]] || fail 'The package launcher checksum is missing.' +for package_file in "${launcher_path}" "${checksum_path}"; do + IFS=: read -r owner group package_file_mode < <(stat -c '%u:%g:%a' -- "${package_file}") + [[ "${owner}" == 0 && "${group}" == 0 ]] || fail 'A package input is not root-owned.' + mode_value=$((8#${package_file_mode})) + (( (mode_value & 07022) == 0 )) || fail 'A package input is writable or has special permission bits.' +done checksum_size=$(stat -c '%s' -- "${checksum_path}") (( checksum_size <= 128 )) || fail 'The package launcher checksum is too large.' diff --git a/docs/carry-divergence-ledger.md b/docs/carry-divergence-ledger.md index a0bfd9a1a2af..5112c51a13ba 100644 --- a/docs/carry-divergence-ledger.md +++ b/docs/carry-divergence-ledger.md @@ -3954,13 +3954,20 @@ and reject both writable and non-traversable parents. Linux release and branch-preview archives package the fixed installer with the launcher and its checksum for x86_64 and Arm64. It verifies the staged launcher -bytes against that package checksum, changes only `/usr/libexec/codex`, and -atomically replaces the fixed root-owned executable after preserving the exact -predecessor as a root-only digest-named rollback image. The directory is -root-owned mode `0711` so invoking users can traverse the path without listing -or writing it. Installation never starts or reloads the unit. Packaging does -not create the host policy, credentials, service account, issuer appointment, -or live execution authority. +bytes against that package checksum, but the checksum alone is not artifact +provenance. The existing artifact receiver must verify the selected qualified +archive, then place its installer, launcher, and checksum in a root-owned, +non-group/world-writable staging directory before invoking the installer. The +installer accepts only a direct child of root-owned `/run`; this prevents an +unprivileged parent from replacing a checked staging directory between input +validation and use. The hosted smoke rejects both user-owned staging and a +root-owned directory under a user-controlled parent. The installer changes +only `/usr/libexec/codex` and atomically replaces the fixed root-owned +executable after preserving the exact predecessor as a root-only digest-named +rollback image. The directory is root-owned mode `0711` so invoking users can +traverse the path without listing or writing it. Installation never starts or +reloads the unit. Packaging does not create the host policy, credentials, +service account, issuer appointment, or live execution authority. Curated plugin ZIP extraction applies Unix permissions only after masking to ordinary `0o777` bits. This keeps executable plugin files usable while making diff --git a/docs/downstream-regression-matrix.md b/docs/downstream-regression-matrix.md index 260c3587961f..303c0e4707b4 100644 --- a/docs/downstream-regression-matrix.md +++ b/docs/downstream-regression-matrix.md @@ -1192,13 +1192,18 @@ pins. Linux release and branch-preview packaging must include the launcher, fixed installer, launcher checksum and its systemd unit template in x86_64 and Arm64 archives, verify the architecture and launcher/installer digests, and keep the template inert. The installer may change only `/usr/libexec/codex`: it -enforces root ownership and mode `0711`, verifies the staged launcher bytes, -preserves the exact predecessor as a root-only digest-named rollback image, and -uses atomic replacement without starting or reloading the unit. The -installed-image regression requires invoking-user traversal through trusted -parents and still rejects writable parents. Source or package proof does not -establish policy/credential provisioning, service installation, issuer -appointment, or first use. Its runtime-home regression +rejects inputs outside a root-owned, non-group/world-writable stage after the +receiver verifies the selected archive. Its accepted stage is a direct child +of root-owned `/run`, so a user-controlled ancestor cannot replace the checked +directory. Hosted installation coverage rejects a user-owned stage and a +root-owned stage under a user-controlled parent, then enforces root ownership +and mode `0711`, verifies the staged launcher bytes, preserves the exact +predecessor as a root-only digest-named rollback image, and uses atomic +replacement without starting or reloading the unit. The installed-image +regression requires invoking-user traversal through trusted parents and still +rejects writable parents. Source or package proof does not establish +policy/credential provisioning, service installation, issuer appointment, or +first use. Its runtime-home regression checks that the child can write its private state and read its installation ID without changing generated configuration or adding CODEX_HOME settings. From 74061c8e4c55c9fc9fbaf0ffba192b3da897b449 Mon Sep 17 00:00:00 2001 From: GraciousGazelles <55840159+GraciousGazelles@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:19:39 +1100 Subject: [PATCH 3/4] Document verified launcher artifact staging --- docs/carry-divergence-ledger.md | 37 ++++++++++++++++++++------------- docs/divergences/index.yaml | 3 ++- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/docs/carry-divergence-ledger.md b/docs/carry-divergence-ledger.md index 5112c51a13ba..2e32916b6576 100644 --- a/docs/carry-divergence-ledger.md +++ b/docs/carry-divergence-ledger.md @@ -3953,21 +3953,28 @@ checks; regressions cover a valid executable in traversable trusted parents, and reject both writable and non-traversable parents. Linux release and branch-preview archives package the fixed installer with the -launcher and its checksum for x86_64 and Arm64. It verifies the staged launcher -bytes against that package checksum, but the checksum alone is not artifact -provenance. The existing artifact receiver must verify the selected qualified -archive, then place its installer, launcher, and checksum in a root-owned, -non-group/world-writable staging directory before invoking the installer. The -installer accepts only a direct child of root-owned `/run`; this prevents an -unprivileged parent from replacing a checked staging directory between input -validation and use. The hosted smoke rejects both user-owned staging and a -root-owned directory under a user-controlled parent. The installer changes -only `/usr/libexec/codex` and atomically replaces the fixed root-owned -executable after preserving the exact predecessor as a root-only digest-named -rollback image. The directory is root-owned mode `0711` so invoking users can -traverse the path without listing or writing it. Installation never starts or -reloads the unit. Packaging does not create the host policy, credentials, -service account, issuer appointment, or live execution authority. +launcher and its checksum for x86_64 and Arm64. The supported installation +procedure uses the existing authenticated GitHub artifact API/download path: +verify the repository, workflow run, source commit, artifact identity, and +API-reported artifact digest against the accepted qualified build before +extracting any package member. Then create a fresh root-owned mode `0700` +directory directly under `/run` named +`codex-runtime-proof-installer.XXXXXX`, copy only the installer, launcher, and +checksum there as root-owned files with safe modes, and invoke the fixed +installer from that directory. Do not use an untrusted extraction directory +or treat the package's adjacent launcher checksum as provider provenance. +The installer checks the root-owned `/run` parent, direct-child stage, regular +no-follow inputs, package ownership, modes, and launcher bytes against the +package checksum. This prevents an unprivileged parent from replacing a +checked staging directory between input validation and use. The hosted smoke +rejects both user-owned staging and a root-owned directory under a +user-controlled parent. The installer changes only `/usr/libexec/codex` and +atomically replaces the fixed root-owned executable after preserving the exact +predecessor as a root-only digest-named rollback image. The directory is +root-owned mode `0711` so invoking users can traverse the path without listing +or writing it. Installation never starts or reloads the unit. Packaging does +not create the host policy, credentials, service account, issuer appointment, +or live execution authority. Curated plugin ZIP extraction applies Unix permissions only after masking to ordinary `0o777` bits. This keeps executable plugin files usable while making diff --git a/docs/divergences/index.yaml b/docs/divergences/index.yaml index 368b67974bbe..62e5e886a0d5 100644 --- a/docs/divergences/index.yaml +++ b/docs/divergences/index.yaml @@ -3759,6 +3759,7 @@ "codex-rs/runtime-proof/src/api_claim_tests.rs", "codex-rs/runtime-proof/src/api_bearer_provisioner.rs", "codex-rs/runtime-proof/src/bin/codex_runtime_proof_launcher.rs", + "codex-rs/runtime-proof/install_runtime_proof_launcher", "codex-rs/runtime-proof/Cargo.toml", "codex-rs/Cargo.lock", "codex-rs/runtime-proof/systemd/codex-runtime-proof-claim.service", @@ -3773,7 +3774,7 @@ ".github/workflows/sedna-branch-build.yml", ".github/workflows/sedna-release.yml" ], - "behavior": "A fixed root-owned Linux one-shot launcher selects one closed MCP or HTTP claim target. Its MCP target starts the measured codex-dev ELF, drops the child to its configured unprivileged UID/GID, verifies the executed file object and child identity before opening systemd LoadCredential inputs, and sends the existing bounded root-peer proof/authentication frames. Its HTTP target measures the running launcher ELF itself before reading a separately provisioned API bearer, then sends only the fixed POST /v0/work-items/claim operation. The root-only provision-api-bearer mode runs only through the installed root-owned launcher under interactive sudo. SUDO_UID selects only the unprivileged API-profile child and optional browser process; the OAuth token and later root policy, not caller metadata, own the subject binding. Its exact --no-browser form uses the same authorization-code and PKCE flow without a browser opener or local callback listener, displaying the generated authorization URL and accepting a complete callback URL only on the invoking TTY with input echo suppressed and prior terminal settings restored on success, error, or handled cancellation signals. It binds callback scheme, loopback host, port, registered path, unique state and unambiguous parameters before the same PKCE exchange and token checks. OAuth exchange failures expose only allowlisted HTTP status/OAuth codes or a closed SDK error category, never provider text. Both forms use the fixed loopback callback and API issuer/resource/client/ops:write scope and write no entry to the persistent MCP credential store. The invoking user's direct OS keyring stores a separate versioned API refresh profile bound to the fixed issuer, client, resource, scope, and canonical subject. Refresh uses the existing lock, persists rotation before releasing only a short-lived access token over a bounded root-created private channel, and fails closed on keyring write failure. It never stores the MCP profile or falls back to the Codex credential file; logout deletes only this API entry. Before the root writes the volatile bearer it checks the fixed issuer, ops-api audience membership, exact ops:write scope, canonical UUID subject, bounded expiry, and any already-installed HTTP policy principal; provisioning may precede policy creation. The configured client’s ops:write scope contributes its existing MCP audiences alongside the client-specific ops-api audience; no shared mapper or scope is changed. The actual HTTP launcher checks the token subject against the root-owned policy before signing or sending. It atomically writes a versioned generation envelope only to /run/credstore/ops-api-bearer, which the fixed unit loads through source-less LoadCredential=ops-api-bearer. After systemd copies it into the private credential directory, the launcher removes only the exact loaded envelope under the same lock as replacement, preserving a replacement even if it contains the same access token. The unit remains conditioned on the separate root claim policy, which this mode neither creates nor activates. The mode accepts no caller-selected URL, client, scope, path or command, and does not create policy or execute a claim. Provisioning failures expose a closed phase and allowlisted HTTP or typed SDK category while suppressing internal error details. Both claim targets use the same root-owned issuer custody and exact operation-bound certificate/proof wire format; the API branch pins agent-ops-api-claim-client, the canonical API recipient, request ID, claim precondition and final-body digest, omits MCP-only fields, and uses one bounded direct request with redirects, retries and request logging disabled. A missing or mismatched API bearer fails closed; API-server signature and caller authentication remain authoritative, and transport uncertainty is never replayed. The MCP target and Linux CLI retain their existing MCP claim behavior. The CLI validates immutable configuration and the effective built-in ChatGPT recipient, imports finite ChatGPT credentials into ephemeral storage, and retains process-scoped signing and selected MCP bearer capabilities. Each native Session contributes a fresh execution nonce, including delegated Sessions that share the MCP manager; a short-lived signature covers the exact final MCP claim request. Certificate SHA-256 claims use the verifier's bare lowercase hexadecimal encoding, while operation digests retain their sha256:-prefixed encoding. Resolved provider configuration is compared with the canonical built-in provider before auth resolution and again before HTTP or websocket transport construction, including provider overrides. A protected MCP target accepts only the reserved local environment and always uses the process-local HTTP client; a remote environment descriptor remains rejected even when paired with the local ID. Until the credential-bearing managed-cloud configuration consumer is admitted to the protected recipient contract, eligible business, enterprise and education protected accounts fail closed before credential import and cloud-loader startup; ordinary Codex cloud configuration behavior is unchanged. Synthetic fixtures use only fixed public fake credentials and loopback recipients. Ordinary invocations remain unconfigured when no protected bootstrap is present. Linux release and branch-preview archives carry a fixed installer with a launcher checksum; it sets only /usr/libexec/codex to root-owned mode 0711 and atomically replaces the fixed root-owned executable after preserving an exact root-only rollback image, without starting or reloading the unit.", + "behavior": "A fixed root-owned Linux one-shot launcher selects one closed MCP or HTTP claim target. Its MCP target starts the measured codex-dev ELF, drops the child to its configured unprivileged UID/GID, verifies the executed file object and child identity before opening systemd LoadCredential inputs, and sends the existing bounded root-peer proof/authentication frames. Its HTTP target measures the running launcher ELF itself before reading a separately provisioned API bearer, then sends only the fixed POST /v0/work-items/claim operation. The root-only provision-api-bearer mode runs only through the installed root-owned launcher under interactive sudo. SUDO_UID selects only the unprivileged API-profile child and optional browser process; the OAuth token and later root policy, not caller metadata, own the subject binding. Its exact --no-browser form uses the same authorization-code and PKCE flow without a browser opener or local callback listener, displaying the generated authorization URL and accepting a complete callback URL only on the invoking TTY with input echo suppressed and prior terminal settings restored on success, error, or handled cancellation signals. It binds callback scheme, loopback host, port, registered path, unique state and unambiguous parameters before the same PKCE exchange and token checks. OAuth exchange failures expose only allowlisted HTTP status/OAuth codes or a closed SDK error category, never provider text. Both forms use the fixed loopback callback and API issuer/resource/client/ops:write scope and write no entry to the persistent MCP credential store. The invoking user's direct OS keyring stores a separate versioned API refresh profile bound to the fixed issuer, client, resource, scope, and canonical subject. Refresh uses the existing lock, persists rotation before releasing only a short-lived access token over a bounded root-created private channel, and fails closed on keyring write failure. It never stores the MCP profile or falls back to the Codex credential file; logout deletes only this API entry. Before the root writes the volatile bearer it checks the fixed issuer, ops-api audience membership, exact ops:write scope, canonical UUID subject, bounded expiry, and any already-installed HTTP policy principal; provisioning may precede policy creation. The configured client’s ops:write scope contributes its existing MCP audiences alongside the client-specific ops-api audience; no shared mapper or scope is changed. The actual HTTP launcher checks the token subject against the root-owned policy before signing or sending. It atomically writes a versioned generation envelope only to /run/credstore/ops-api-bearer, which the fixed unit loads through source-less LoadCredential=ops-api-bearer. After systemd copies it into the private credential directory, the launcher removes only the exact loaded envelope under the same lock as replacement, preserving a replacement even if it contains the same access token. The unit remains conditioned on the separate root claim policy, which this mode neither creates nor activates. The mode accepts no caller-selected URL, client, scope, path or command, and does not create policy or execute a claim. Provisioning failures expose a closed phase and allowlisted HTTP or typed SDK category while suppressing internal error details. Both claim targets use the same root-owned issuer custody and exact operation-bound certificate/proof wire format; the API branch pins agent-ops-api-claim-client, the canonical API recipient, request ID, claim precondition and final-body digest, omits MCP-only fields, and uses one bounded direct request with redirects, retries and request logging disabled. A missing or mismatched API bearer fails closed; API-server signature and caller authentication remain authoritative, and transport uncertainty is never replayed. The MCP target and Linux CLI retain their existing MCP claim behavior. The CLI validates immutable configuration and the effective built-in ChatGPT recipient, imports finite ChatGPT credentials into ephemeral storage, and retains process-scoped signing and selected MCP bearer capabilities. Each native Session contributes a fresh execution nonce, including delegated Sessions that share the MCP manager; a short-lived signature covers the exact final MCP claim request. Certificate SHA-256 claims use the verifier's bare lowercase hexadecimal encoding, while operation digests retain their sha256:-prefixed encoding. Resolved provider configuration is compared with the canonical built-in provider before auth resolution and again before HTTP or websocket transport construction, including provider overrides. A protected MCP target accepts only the reserved local environment and always uses the process-local HTTP client; a remote environment descriptor remains rejected even when paired with the local ID. Until the credential-bearing managed-cloud configuration consumer is admitted to the protected recipient contract, eligible business, enterprise and education protected accounts fail closed before credential import and cloud-loader startup; ordinary Codex cloud configuration behavior is unchanged. Synthetic fixtures use only fixed public fake credentials and loopback recipients. Ordinary invocations remain unconfigured when no protected bootstrap is present. Linux release and branch-preview archives carry a fixed installer with a launcher checksum. The existing authenticated GitHub artifact API/download path verifies repository, workflow run, source commit, artifact identity, and API-reported artifact digest against the accepted qualified build before package extraction; it then copies only the installer, launcher, and checksum into a fresh root-owned mode-0700 direct child of /run named codex-runtime-proof-installer.XXXXXX and invokes the fixed installer there. The installer checks its root-owned /run parent, direct-child package stage, regular no-follow root-owned inputs, safe modes, and launcher bytes against the package checksum; that checksum is package consistency, not provider provenance. It sets only /usr/libexec/codex to root-owned mode 0711 and atomically replaces the fixed root-owned executable after preserving an exact root-only rollback image, without starting or reloading the unit.", "upstream_equivalent": false, "introduced_in": {"carry_commit": null, "upstream_commit": null}, "guardrail_lane": "core-runtime-surface-smoke; codex.mcp-safety-targeted; codex.downstream-docs-check; sedna-branch-build; sedna-release", From 736f4890e07535ef285a7d529de927bd62ad539f Mon Sep 17 00:00:00 2001 From: GraciousGazelles <55840159+GraciousGazelles@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:48:01 +1100 Subject: [PATCH 4/4] Compare installer modes numerically --- codex-rs/runtime-proof/install_runtime_proof_launcher | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codex-rs/runtime-proof/install_runtime_proof_launcher b/codex-rs/runtime-proof/install_runtime_proof_launcher index e875fd6b2be8..8989a9c149fc 100755 --- a/codex-rs/runtime-proof/install_runtime_proof_launcher +++ b/codex-rs/runtime-proof/install_runtime_proof_launcher @@ -133,7 +133,8 @@ if [[ "${previous_digest}" != "${expected_digest}" ]]; then if [[ -e "${rollback_path}" || -L "${rollback_path}" ]]; then [[ -f "${rollback_path}" && ! -L "${rollback_path}" ]] || fail 'The matching rollback path is unsafe.' IFS=: read -r owner group rollback_mode < <(stat -c '%u:%g:%a' -- "${rollback_path}") - [[ "${owner}" == 0 && "${group}" == 0 && "${rollback_mode}" == 600 ]] || fail 'The matching rollback image is outside root-only custody.' + mode_value=$((8#${rollback_mode})) + [[ "${owner}" == 0 && "${group}" == 0 ]] && (( mode_value == 0600 )) || fail 'The matching rollback image is outside root-only custody.' rollback_digest=$(sha256sum < "${rollback_path}") [[ "${rollback_digest%% *}" == "${previous_digest}" ]] || fail 'The matching rollback image digest does not match.' else @@ -155,7 +156,7 @@ if [[ "${previous_digest}" != "${expected_digest}" ]]; then temporary_path= replacement_complete=1 else - if [[ -n "${parent_mode_before}" && "${parent_mode_before}" != 711 ]]; then + if [[ -n "${parent_mode_before}" ]] && (( 8#${parent_mode_before} != 0711 )); then chmod 0711 -- "${target_dir}" parent_mode_changed=1 fi