diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6494759e3e0f..fd2a5f3c3980 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,9 @@ jobs: - name: Validate cmux profiling support scripts run: ./tests/test_start_cmux_profiling.sh + - name: Validate bundled provider runtime guard + run: ./tests/test_bundled_provider_runtime_guard.sh + - name: Validate Xcode SourcePackages cache sanitizer run: python3 tests/test_ci_sanitize_xcode_source_packages_cache.py @@ -1379,6 +1382,8 @@ jobs: - name: Validate Release artifact slices run: | set -euo pipefail + ./scripts/verify-no-bundled-agent-runtimes.sh \ + build-universal/Build/Products/Release/cmux.app APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux" CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" HELPER_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/ghostty" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0d7baabcca52..32015f3bceaa 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -282,6 +282,13 @@ jobs: CMUX_APP_PATH="build-universal/Build/Products/Release/cmux.app" \ ./tests/test_bundled_ghostty_theme_picker_helper.sh + - name: Verify no bundled provider runtimes in nightly app + if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' + run: | + set -euo pipefail + ./scripts/verify-no-bundled-agent-runtimes.sh \ + build-universal/Build/Products/Release/cmux.app + - name: Verify nightly binary architectures if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 24c9f9aca873..ede19e8e5e85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -282,6 +282,8 @@ jobs: if: steps.guard_release_assets.outputs.skip_all != 'true' run: | set -euo pipefail + ./scripts/verify-no-bundled-agent-runtimes.sh \ + build-universal/Build/Products/Release/cmux.app APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux" CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" HELPER_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/ghostty" diff --git a/scripts/verify-no-bundled-agent-runtimes.sh b/scripts/verify-no-bundled-agent-runtimes.sh new file mode 100755 index 000000000000..792bda48c2c0 --- /dev/null +++ b/scripts/verify-no-bundled-agent-runtimes.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EXPECTED_GROK_WRAPPER="$SCRIPT_DIR/../Resources/bin/grok" + +usage() { + cat <<'EOF' +Usage: scripts/verify-no-bundled-agent-runtimes.sh + +Verifies that a built cmux app bundle does not ship provider executables or a +Bun standalone runtime in Contents/Resources/bin. +EOF +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage + exit 0 +fi + +if [ "$#" -ne 1 ]; then + usage >&2 + exit 2 +fi + +APP_PATH="$1" +BIN_DIR="$APP_PATH/Contents/Resources/bin" + +if [ ! -d "$APP_PATH" ]; then + echo "error: app bundle not found at $APP_PATH" >&2 + exit 1 +fi + +if [ ! -d "$BIN_DIR" ]; then + echo "error: bundled bin directory not found at $BIN_DIR" >&2 + exit 1 +fi + +is_allowed_binary_name() { + case "$1" in + # `grok` is allowed only when it matches cmux's checked-in wrapper script; + # it still goes through the Bun-standalone signature scan below. + cmux|ghostty|cmux-claude-wrapper|cmux-codex-wrapper|grok|open|start-cmux-profiling|submit-cmux-profile) + return 0 + ;; + *) + return 1 + ;; + esac +} + +looks_like_bun_standalone() { + local path="$1" + strings -a "$path" 2>/dev/null | grep -E '(/\$bunfs/|StandaloneExecutable|Bun v[0-9]+\.[0-9]+\.[0-9]+)' >/dev/null +} + +is_checked_in_grok_wrapper() { + local path="$1" + [ -f "$EXPECTED_GROK_WRAPPER" ] && cmp -s "$EXPECTED_GROK_WRAPPER" "$path" +} + +relative_to_app() { + local path="$1" + printf '%s\n' "${path#"$APP_PATH"/}" +} + +violations=() + +while IFS= read -r -d '' file; do + name="$(basename "$file")" + if ! is_allowed_binary_name "$name"; then + violations+=("unexpected bundled bin entry: $(relative_to_app "$file")") + continue + fi + if [ ! -x "$file" ] && [ ! -L "$file" ]; then + violations+=("allowed bin entry is not executable: $(relative_to_app "$file")") + fi + if [ "$name" = "grok" ] && ! is_checked_in_grok_wrapper "$file"; then + violations+=("grok wrapper does not match checked-in cmux wrapper: $(relative_to_app "$file")") + fi + if looks_like_bun_standalone "$file"; then + violations+=("Bun standalone runtime signature: $(relative_to_app "$file")") + fi +done < <(find "$BIN_DIR" \( -type f -o -type l \) -print0) + +if [ "${#violations[@]}" -gt 0 ]; then + echo "error: cmux app bundle contains forbidden bundled provider runtimes:" >&2 + printf ' %s\n' "${violations[@]}" >&2 + exit 1 +fi + +echo "verified no bundled provider runtimes: $APP_PATH" diff --git a/tests/test_bundled_provider_runtime_guard.sh b/tests/test_bundled_provider_runtime_guard.sh new file mode 100755 index 000000000000..7a449c24b026 --- /dev/null +++ b/tests/test_bundled_provider_runtime_guard.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERIFY_SCRIPT="$ROOT_DIR/scripts/verify-no-bundled-agent-runtimes.sh" + +if [ ! -x "$VERIFY_SCRIPT" ]; then + echo "FAIL: missing bundled provider runtime verifier at $VERIFY_SCRIPT" >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-bundled-runtime-guard.XXXXXX")" +trap 'rm -rf "$TMP_DIR"' EXIT + +make_app() { + local app_path="$1" + mkdir -p "$app_path/Contents/Resources/bin" +} + +write_executable() { + local path="$1" + local body="$2" + mkdir -p "$(dirname "$path")" + printf '%s\n' "$body" > "$path" + chmod 0755 "$path" +} + +copy_grok_wrapper() { + local path="$1" + mkdir -p "$(dirname "$path")" + install -m 0755 "$ROOT_DIR/Resources/bin/grok" "$path" +} + +GOOD_APP="$TMP_DIR/good/cmux.app" +make_app "$GOOD_APP" +write_executable "$GOOD_APP/Contents/Resources/bin/cmux" "#!/bin/sh" +write_executable "$GOOD_APP/Contents/Resources/bin/ghostty" "#!/bin/sh" +write_executable "$GOOD_APP/Contents/Resources/bin/cmux-claude-wrapper" "#!/bin/sh" +write_executable "$GOOD_APP/Contents/Resources/bin/cmux-codex-wrapper" "#!/bin/sh" +copy_grok_wrapper "$GOOD_APP/Contents/Resources/bin/grok" +write_executable "$GOOD_APP/Contents/Resources/bin/open" "#!/bin/sh" +write_executable "$GOOD_APP/Contents/Resources/bin/start-cmux-profiling" "#!/bin/sh" +write_executable "$GOOD_APP/Contents/Resources/bin/submit-cmux-profile" "#!/bin/sh" +"$VERIFY_SCRIPT" "$GOOD_APP" + +BAD_GROK_APP="$TMP_DIR/bad-grok-wrapper/cmux.app" +make_app "$BAD_GROK_APP" +write_executable "$BAD_GROK_APP/Contents/Resources/bin/cmux" "#!/bin/sh" +write_executable "$BAD_GROK_APP/Contents/Resources/bin/grok" "#!/bin/sh" +if "$VERIFY_SCRIPT" "$BAD_GROK_APP" >"$TMP_DIR/grok-wrapper.out" 2>&1; then + echo "FAIL: verifier allowed a non-cmux grok wrapper" >&2 + exit 1 +fi +if ! grep -Fq "grok wrapper does not match checked-in cmux wrapper: Contents/Resources/bin/grok" "$TMP_DIR/grok-wrapper.out"; then + echo "FAIL: grok wrapper rejection did not name the offending path" >&2 + cat "$TMP_DIR/grok-wrapper.out" >&2 + exit 1 +fi + +for forbidden in claude opencode codex pi bun bunx; do + BAD_APP="$TMP_DIR/bad-$forbidden/cmux.app" + make_app "$BAD_APP" + write_executable "$BAD_APP/Contents/Resources/bin/cmux" "#!/bin/sh" + write_executable "$BAD_APP/Contents/Resources/bin/$forbidden" "#!/bin/sh" + OUTPUT="$TMP_DIR/$forbidden.out" + if "$VERIFY_SCRIPT" "$BAD_APP" >"$OUTPUT" 2>&1; then + echo "FAIL: verifier allowed bundled $forbidden executable" >&2 + exit 1 + fi + if ! grep -Fq "Contents/Resources/bin/$forbidden" "$OUTPUT"; then + echo "FAIL: verifier rejection for $forbidden did not name the offending path" >&2 + cat "$OUTPUT" >&2 + exit 1 + fi +done + +NONEXEC_FORBIDDEN_APP="$TMP_DIR/bad-nonexec-bun/cmux.app" +make_app "$NONEXEC_FORBIDDEN_APP" +write_executable "$NONEXEC_FORBIDDEN_APP/Contents/Resources/bin/cmux" "#!/bin/sh" +printf '%s\n' 'Bun v1.3.14 StandaloneExecutable /$bunfs/root' > "$NONEXEC_FORBIDDEN_APP/Contents/Resources/bin/bun" +chmod 0644 "$NONEXEC_FORBIDDEN_APP/Contents/Resources/bin/bun" +if "$VERIFY_SCRIPT" "$NONEXEC_FORBIDDEN_APP" >"$TMP_DIR/nonexec-bun.out" 2>&1; then + echo "FAIL: verifier allowed a non-executable bundled bun file" >&2 + exit 1 +fi +if ! grep -Fq "unexpected bundled bin entry: Contents/Resources/bin/bun" "$TMP_DIR/nonexec-bun.out"; then + echo "FAIL: non-executable bun rejection did not name the offending path" >&2 + cat "$TMP_DIR/nonexec-bun.out" >&2 + exit 1 +fi + +NONEXEC_ALLOWED_APP="$TMP_DIR/bad-nonexec-allowed/cmux.app" +make_app "$NONEXEC_ALLOWED_APP" +printf '%s\n' '#!/bin/sh' > "$NONEXEC_ALLOWED_APP/Contents/Resources/bin/cmux" +chmod 0644 "$NONEXEC_ALLOWED_APP/Contents/Resources/bin/cmux" +if "$VERIFY_SCRIPT" "$NONEXEC_ALLOWED_APP" >"$TMP_DIR/nonexec-allowed.out" 2>&1; then + echo "FAIL: verifier allowed a non-executable allowlisted bin entry" >&2 + exit 1 +fi +if ! grep -Fq "allowed bin entry is not executable: Contents/Resources/bin/cmux" "$TMP_DIR/nonexec-allowed.out"; then + echo "FAIL: non-executable allowlisted rejection did not name the offending path" >&2 + cat "$TMP_DIR/nonexec-allowed.out" >&2 + exit 1 +fi + +SYMLINK_APP="$TMP_DIR/bad-symlink/cmux.app" +make_app "$SYMLINK_APP" +write_executable "$SYMLINK_APP/Contents/Resources/bin/cmux" "#!/bin/sh" +ln -s cmux "$SYMLINK_APP/Contents/Resources/bin/claude" +if "$VERIFY_SCRIPT" "$SYMLINK_APP" >"$TMP_DIR/symlink.out" 2>&1; then + echo "FAIL: verifier allowed a symlinked bundled claude executable" >&2 + exit 1 +fi +if ! grep -Fq "Contents/Resources/bin/claude" "$TMP_DIR/symlink.out"; then + echo "FAIL: symlink rejection did not name the offending path" >&2 + cat "$TMP_DIR/symlink.out" >&2 + exit 1 +fi + +BUN_SIGNATURE_APP="$TMP_DIR/bad-bun-signature/cmux.app" +make_app "$BUN_SIGNATURE_APP" +write_executable "$BUN_SIGNATURE_APP/Contents/Resources/bin/cmux" '#!/bin/sh +printf "%s\n" "Bun v1.3.14 StandaloneExecutable /$bunfs/root"' +if "$VERIFY_SCRIPT" "$BUN_SIGNATURE_APP" >"$TMP_DIR/bun-signature.out" 2>&1; then + echo "FAIL: verifier allowed an allowlisted executable with a Bun standalone signature" >&2 + exit 1 +fi +if ! grep -Fq "Bun standalone runtime signature: Contents/Resources/bin/cmux" "$TMP_DIR/bun-signature.out"; then + echo "FAIL: Bun signature rejection did not name the offending path" >&2 + cat "$TMP_DIR/bun-signature.out" >&2 + exit 1 +fi + +LARGE_BUN_SIGNATURE_APP="$TMP_DIR/bad-large-bun-signature/cmux.app" +make_app "$LARGE_BUN_SIGNATURE_APP" +{ + printf '%s\n' '#!/bin/sh' + printf '%s\n' 'Bun v1.3.14 StandaloneExecutable /$bunfs/root' + awk 'BEGIN { for (i = 0; i < 5000; i++) print "cmux padding line after the early Bun signature" }' +} > "$LARGE_BUN_SIGNATURE_APP/Contents/Resources/bin/cmux" +chmod 0755 "$LARGE_BUN_SIGNATURE_APP/Contents/Resources/bin/cmux" +if "$VERIFY_SCRIPT" "$LARGE_BUN_SIGNATURE_APP" >"$TMP_DIR/large-bun-signature.out" 2>&1; then + echo "FAIL: verifier allowed a large allowlisted executable with an early Bun standalone signature" >&2 + exit 1 +fi +if ! grep -Fq "Bun standalone runtime signature: Contents/Resources/bin/cmux" "$TMP_DIR/large-bun-signature.out"; then + echo "FAIL: large Bun signature rejection did not name the offending path" >&2 + cat "$TMP_DIR/large-bun-signature.out" >&2 + exit 1 +fi + +echo "PASS: bundled provider runtime guard rejects stale provider and Bun executables"