diff --git a/.github/dotslash-bash-config.json b/.github/dotslash-bash-config.json new file mode 100644 index 0000000000..59c46831f0 --- /dev/null +++ b/.github/dotslash-bash-config.json @@ -0,0 +1,32 @@ +{ + "outputs": { + "codex-bash": { + "platforms": { + "macos-aarch64": { + "name": "codex-bash-aarch64-apple-darwin.tar.gz", + "format": "tar.gz", + "hash": "sha256", + "path": "codex-bash/bin/bash" + }, + "macos-x86_64": { + "name": "codex-bash-x86_64-apple-darwin.tar.gz", + "format": "tar.gz", + "hash": "sha256", + "path": "codex-bash/bin/bash" + }, + "linux-x86_64": { + "name": "codex-bash-x86_64-unknown-linux-musl.tar.gz", + "format": "tar.gz", + "hash": "sha256", + "path": "codex-bash/bin/bash" + }, + "linux-aarch64": { + "name": "codex-bash-aarch64-unknown-linux-musl.tar.gz", + "format": "tar.gz", + "hash": "sha256", + "path": "codex-bash/bin/bash" + } + } + } + } +} diff --git a/.github/scripts/build-bash-release-artifact.sh b/.github/scripts/build-bash-release-artifact.sh new file mode 100755 index 0000000000..d19c06ad9c --- /dev/null +++ b/.github/scripts/build-bash-release-artifact.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ "$#" -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +archive_path="$1" +workspace="${GITHUB_WORKSPACE:?missing GITHUB_WORKSPACE}" +bash_commit="${BASH_COMMIT:?missing BASH_COMMIT}" +bash_patch="${BASH_PATCH:?missing BASH_PATCH}" +temp_root="${RUNNER_TEMP:-/tmp}" +work_root="$(mktemp -d "${temp_root%/}/codex-bash-release.XXXXXX")" +trap 'rm -rf "$work_root"' EXIT + +source_root="${work_root}/bash" +package_root="${work_root}/codex-bash" +wrapper_path="${work_root}/exec-wrapper" +stdout_path="${work_root}/stdout.txt" +wrapper_log_path="${work_root}/wrapper.log" +socket_probe_path="${work_root}/socket-probe.txt" + +git clone https://git.savannah.gnu.org/git/bash "$source_root" +cd "$source_root" +git checkout "$bash_commit" +git apply "${workspace}/${bash_patch}" +./configure --without-bash-malloc + +cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" +make -j"${cores}" + +# Stand in for codex-execve-wrapper: record each intercepted executable and +# prove that the inherited escalation-socket descriptor is still open. +cat > "$wrapper_path" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}" +: "${EXEC_WRAPPER:?missing EXEC_WRAPPER}" +: "${CODEX_ESCALATE_SOCKET:?missing CODEX_ESCALATE_SOCKET}" +printf 'socket-open\n' >&"${CODEX_ESCALATE_SOCKET}" +printf '%s\n' "$@" >> "$CODEX_WRAPPER_LOG" +file="$1" +shift +if [[ "$#" -eq 0 ]]; then + exec "$file" +fi +arg0="$1" +shift +exec -a "$arg0" "$file" "$@" +EOF +chmod +x "$wrapper_path" + +# The nested bash and /bin/echo should each pass through EXEC_WRAPPER while +# retaining the same inherited descriptor. +CODEX_WRAPPER_LOG="$wrapper_log_path" \ +CODEX_ESCALATE_SOCKET=9 \ +EXEC_WRAPPER="$wrapper_path" \ +"${source_root}/bash" \ + -c "\"${source_root}/bash\" -c '/bin/echo smoke-bash'" \ + > "$stdout_path" \ + 9> "$socket_probe_path" + +grep -Fx "smoke-bash" "$stdout_path" +grep -Fx "${source_root}/bash" "$wrapper_log_path" +grep -Fx "/bin/echo" "$wrapper_log_path" +[[ "$(grep -Fxc "socket-open" "$socket_probe_path")" -eq 2 ]] + +mkdir -p "$package_root/bin" "$(dirname "${workspace}/${archive_path}")" +cp "${source_root}/bash" "$package_root/bin/bash" +chmod +x "$package_root/bin/bash" + +(cd "$work_root" && tar -czf "${workspace}/${archive_path}" codex-bash) diff --git a/.github/scripts/build-zsh-release-artifact.sh b/.github/scripts/build-zsh-release-artifact.sh index 4fc3db3903..b1f776ba07 100755 --- a/.github/scripts/build-zsh-release-artifact.sh +++ b/.github/scripts/build-zsh-release-artifact.sh @@ -20,6 +20,7 @@ package_root="${work_root}/codex-zsh" wrapper_path="${work_root}/exec-wrapper" stdout_path="${work_root}/stdout.txt" wrapper_log_path="${work_root}/wrapper.log" +socket_probe_path="${work_root}/socket-probe.txt" git clone https://git.code.sf.net/p/zsh/code "$source_root" cd "$source_root" @@ -31,11 +32,16 @@ git apply "${workspace}/${zsh_patch}" cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" make -j"${cores}" +# Stand in for codex-execve-wrapper: record each intercepted executable and +# prove that the inherited escalation-socket descriptor is still open. cat > "$wrapper_path" <<'EOF' #!/usr/bin/env bash set -euo pipefail : "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}" -printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG" +: "${EXEC_WRAPPER:?missing EXEC_WRAPPER}" +: "${CODEX_ESCALATE_SOCKET:?missing CODEX_ESCALATE_SOCKET}" +printf 'socket-open\n' >&"${CODEX_ESCALATE_SOCKET}" +printf '%s\n' "$@" >> "$CODEX_WRAPPER_LOG" file="$1" shift if [[ "$#" -eq 0 ]]; then @@ -47,12 +53,20 @@ exec -a "$arg0" "$file" "$@" EOF chmod +x "$wrapper_path" +# The nested zsh and /bin/echo should each pass through EXEC_WRAPPER while +# retaining the same inherited descriptor. CODEX_WRAPPER_LOG="$wrapper_log_path" \ +CODEX_ESCALATE_SOCKET=9 \ EXEC_WRAPPER="$wrapper_path" \ -"${source_root}/Src/zsh" -fc '/bin/echo smoke-zsh' > "$stdout_path" +"${source_root}/Src/zsh" \ + -fc "\"${source_root}/Src/zsh\" -fc '/bin/echo smoke-zsh'" \ + > "$stdout_path" \ + 9> "$socket_probe_path" grep -Fx "smoke-zsh" "$stdout_path" +grep -Fx "${source_root}/Src/zsh" "$wrapper_log_path" grep -Fx "/bin/echo" "$wrapper_log_path" +[[ "$(grep -Fxc "socket-open" "$socket_probe_path")" -eq 2 ]] mkdir -p "$package_root/bin" "$(dirname "${workspace}/${archive_path}")" cp "${source_root}/Src/zsh" "$package_root/bin/zsh" diff --git a/.github/scripts/test-shell-exec-wrapper-chain.sh b/.github/scripts/test-shell-exec-wrapper-chain.sh new file mode 100755 index 0000000000..e78416a584 --- /dev/null +++ b/.github/scripts/test-shell-exec-wrapper-chain.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ "$#" -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +bash_path="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +zsh_path="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" +temp_root="${RUNNER_TEMP:-/tmp}" +work_root="$(mktemp -d "${temp_root%/}/codex-shell-chain.XXXXXX")" +trap 'rm -rf "$work_root"' EXIT + +wrapper_path="${work_root}/exec-wrapper" + +# Stand in for codex-execve-wrapper: record each intercepted executable and +# prove that the inherited escalation-socket descriptor is still open. +cat > "$wrapper_path" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}" +: "${EXEC_WRAPPER:?missing EXEC_WRAPPER}" +: "${CODEX_ESCALATE_SOCKET:?missing CODEX_ESCALATE_SOCKET}" +printf 'socket-open\n' >&"${CODEX_ESCALATE_SOCKET}" +printf '%s\n' "$@" >> "$CODEX_WRAPPER_LOG" +file="$1" +shift +if [[ "$#" -eq 0 ]]; then + exec "$file" +fi +arg0="$1" +shift +exec -a "$arg0" "$file" "$@" +EOF +chmod +x "$wrapper_path" + +run_chain() { + local outer_shell="$1" + local outer_flag="$2" + local inner_shell="$3" + local inner_flag="$4" + local marker="$5" + local wrapper_log="${work_root}/${marker}-wrapper.log" + local socket_probe="${work_root}/${marker}-socket.log" + local stdout="${work_root}/${marker}-stdout.txt" + local command + command="\"${inner_shell}\" ${inner_flag} '/bin/echo ${marker}'" + + # The inner shell and /bin/echo should each pass through EXEC_WRAPPER while + # retaining the same inherited descriptor. + CODEX_WRAPPER_LOG="$wrapper_log" \ + CODEX_ESCALATE_SOCKET=9 \ + EXEC_WRAPPER="$wrapper_path" \ + "$outer_shell" "$outer_flag" "$command" > "$stdout" 9> "$socket_probe" + + grep -Fx "$marker" "$stdout" + grep -Fx "$inner_shell" "$wrapper_log" + grep -Fx "/bin/echo" "$wrapper_log" + [[ "$(grep -Fxc "socket-open" "$socket_probe")" -eq 2 ]] +} + +# Either patched shell may launch the other, so exercise both directions. +run_chain "$bash_path" -c "$zsh_path" -fc bash-zsh-chain +run_chain "$zsh_path" -fc "$bash_path" -c zsh-bash-chain diff --git a/.github/workflows/rust-release-bash.yml b/.github/workflows/rust-release-bash.yml new file mode 100644 index 0000000000..1fb06ce960 --- /dev/null +++ b/.github/workflows/rust-release-bash.yml @@ -0,0 +1,223 @@ +name: rust-release-bash + +on: + push: + tags: + - "codex-bash-v*.*.*" + +env: + BASH_COMMIT: a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + BASH_PATCH: codex-rs/shell-escalation/patches/bash-exec-wrapper.patch + ZSH_FORK_RELEASE_TAG: codex-zsh-v0.1.0 + +concurrency: + group: ${{ github.workflow }}::${{ github.ref_name }} + cancel-in-progress: false + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.release_tag.outputs.release_tag }} + + steps: + - name: Validate release tag + id: release_tag + env: + RELEASE_TAG: ${{ github.ref_name }} + shell: bash + run: | + set -euo pipefail + + if [[ ! "${RELEASE_TAG}" =~ ^codex-bash-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Tag ${RELEASE_TAG} does not match codex-bash-vX.Y.Z." >&2 + exit 1 + fi + + echo "release_tag=${RELEASE_TAG}" >> "${GITHUB_OUTPUT}" + + - name: Ensure release does not exist + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release_tag.outputs.release_tag }} + shell: bash + run: | + set -euo pipefail + + if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" > /dev/null 2>&1; then + echo "Release ${RELEASE_TAG} already exists; publish changed artifacts under a new tag." >&2 + exit 1 + fi + + linux: + name: Build bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: metadata + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + variant: ubuntu-24.04 + image: ubuntu:24.04 + archive_name: codex-bash-x86_64-unknown-linux-musl.tar.gz + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + variant: ubuntu-24.04 + image: arm64v8/ubuntu:24.04 + archive_name: codex-bash-aarch64-unknown-linux-musl.tar.gz + + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + autoconf \ + bison \ + build-essential \ + ca-certificates \ + gettext \ + git \ + libncursesw5-dev + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Build, smoke-test, and stage bash artifact + shell: bash + run: | + "${GITHUB_WORKSPACE}/.github/scripts/build-bash-release-artifact.sh" \ + "dist/bash/${{ matrix.target }}/${{ matrix.archive_name }}" + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: codex-bash-${{ matrix.target }} + path: dist/bash/${{ matrix.target }}/* + if-no-files-found: error + + darwin: + name: Build bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: metadata + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15-large + target: x86_64-apple-darwin + variant: macos-15 + archive_name: codex-bash-x86_64-apple-darwin.tar.gz + - runner: macos-15-xlarge + target: aarch64-apple-darwin + variant: macos-15 + archive_name: codex-bash-aarch64-apple-darwin.tar.gz + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Build, smoke-test, and stage bash artifact + shell: bash + run: | + "${GITHUB_WORKSPACE}/.github/scripts/build-bash-release-artifact.sh" \ + "dist/bash/${{ matrix.target }}/${{ matrix.archive_name }}" + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: codex-bash-${{ matrix.target }} + path: dist/bash/${{ matrix.target }}/* + if-no-files-found: error + + cross-shell-smoke: + name: Smoke test bash and zsh fork chain + needs: + - linux + runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download bash artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: codex-bash-x86_64-unknown-linux-musl + path: dist/bash + + - name: Download zsh fork artifact + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + mkdir -p dist/zsh + gh release download "${ZSH_FORK_RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern codex-zsh-x86_64-unknown-linux-musl.tar.gz \ + --dir dist/zsh + + - name: Test nested shell forks + shell: bash + run: | + set -euo pipefail + mkdir -p dist/extracted/bash dist/extracted/zsh + tar -xzf dist/bash/codex-bash-x86_64-unknown-linux-musl.tar.gz \ + -C dist/extracted/bash + tar -xzf dist/zsh/codex-zsh-x86_64-unknown-linux-musl.tar.gz \ + -C dist/extracted/zsh + .github/scripts/test-shell-exec-wrapper-chain.sh \ + dist/extracted/bash/codex-bash/bin/bash \ + dist/extracted/zsh/codex-zsh/bin/zsh + + publish-release: + needs: + - metadata + - linux + - darwin + - cross-shell-smoke + runs-on: ubuntu-latest + permissions: + contents: write + actions: read + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + + - name: Create GitHub Release + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + with: + tag_name: ${{ needs.metadata.outputs.release_tag }} + name: ${{ needs.metadata.outputs.release_tag }} + files: dist/** + # Keep bash artifact releases out of Codex's normal "latest release" channel. + prerelease: true + + - name: Publish DotSlash manifest + uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag: ${{ needs.metadata.outputs.release_tag }} + config: .github/dotslash-bash-config.json diff --git a/codex-rs/shell-escalation/README.md b/codex-rs/shell-escalation/README.md index 69cd038220..c6836cfebb 100644 --- a/codex-rs/shell-escalation/README.md +++ b/codex-rs/shell-escalation/README.md @@ -15,6 +15,30 @@ decision to the shell-escalation protocol over a shared file descriptor (specifi - `Deny`: the server has declared the proposed command to be forbidden, so `codex-execve-wrapper` prints an error to `stderr` and exits with `1`. +Both patched shells use `EXEC_WRAPPER` to locate `codex-execve-wrapper` and +preserve `CODEX_ESCALATE_SOCKET` as the inherited escalation socket file +descriptor. This shared environment contract lets intercepted commands pass +through trees containing either shell without losing the escalation session. + +## Patched bash + +We carry a small patch to `execute_cmd.c` (see +`patches/bash-exec-wrapper.patch`) that adds support for `EXEC_WRAPPER`. The +patch applies to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from +https://git.savannah.gnu.org/git/bash. To rebuild manually: + +```bash +git clone https://git.savannah.gnu.org/git/bash +git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b +git apply /path/to/patches/bash-exec-wrapper.patch +./configure --without-bash-malloc +make -j"$(nproc)" +``` + +Release artifacts are built by `.github/workflows/rust-release-bash.yml` when +a `codex-bash-vX.Y.Z` tag is pushed. When the bash commit or patch changes, +publish the next version tag. + ## Patched zsh We carry a small patch to `Src/exec.c` (see `patches/zsh-exec-wrapper.patch`) that adds support for `EXEC_WRAPPER`. The patch applies to `77045ef899e53b9598bebc5a41db93a548a40ca6` from https://git.code.sf.net/p/zsh/code. To rebuild manually: diff --git a/codex-rs/shell-escalation/patches/bash-exec-wrapper.patch b/codex-rs/shell-escalation/patches/bash-exec-wrapper.patch new file mode 100644 index 0000000000..63e480229d --- /dev/null +++ b/codex-rs/shell-escalation/patches/bash-exec-wrapper.patch @@ -0,0 +1,24 @@ +diff --git a/execute_cmd.c b/execute_cmd.c +index 070f5119..d20ad2b9 100644 +--- a/execute_cmd.c ++++ b/execute_cmd.c +@@ -6129,6 +6129,19 @@ shell_execve (char *command, char **args, char **env) + char sample[HASH_BANG_BUFSIZ]; + size_t larray; + ++ char *exec_wrapper = getenv("EXEC_WRAPPER"); ++ if (exec_wrapper && *exec_wrapper && !whitespace (*exec_wrapper)) ++ { ++ char *orig_command = command; ++ ++ larray = strvec_len (args); ++ ++ memmove (args + 2, args, (++larray) * sizeof (char *)); ++ args[0] = exec_wrapper; ++ args[1] = orig_command; ++ command = exec_wrapper; ++ } ++ + SETOSTYPE (0); /* Some systems use for USG/POSIX semantics */ + execve (command, args, env); + i = errno; /* error from execve() */