VMArm Technical Notes

Isolating Untrusted Pull Requests in Cloud Mac CI

Isolating Untrusted Pull Requests in Cloud Mac CI

Once a team allows external contributors to submit pull requests, cloud Mac CI is no longer concerned only with whether the code compiles. The build scripts themselves are untrusted input: they can read environment variables, search home directories, modify shared caches, or even replace tools used by later jobs. The security objective should be explicit: external pull requests may validate compilation and tests, but they must not gain access to release credentials or send their artifacts directly into production releases.

Split the Two Trust Paths First

Separate jobs into a secretless validation path and a trusted release path. The former accepts external pull requests and performs only dependency resolution, compilation, static analysis, and tests that do not require real credentials. The latter accepts only pinned commits that have already been reviewed and merged, and handles signing, archiving, and delivery.

On VMArm dedicated physical nodes, you can create separate macOS users for the two paths. The validation user should not belong to the administrator group, store release certificates, or have access to the release user's home directory. The release user must not reuse scripts, caches, or tool directories that the validation user can modify.

System user isolation is not a virtual machine boundary. It significantly reduces the risk of ordinary scripts accidentally reading credentials or contaminating files, but it does not replace system updates, least privilege, or manual review of untrusted code.

The recommended boundaries are:

Item Secretless validation Trusted release
Code source Pinned external PR commit Pinned commit from a reviewed branch
Code signing Disabled Enabled according to the release process
Workspace Created fresh for every job Separate directory
Cache Disposable and never shared across trust domains Shared only by trusted jobs
Build artifacts Used only for validation Delivered after rebuilding
Credentials Not injected Injected briefly when needed

Prepare a Least-Privilege Workspace

Do not let the runner build indefinitely at the root of a user's home directory. Create a dedicated root directory for validation jobs and ensure that other local users cannot read it. Create a temporary directory when each job begins, then remove it on completion whether the job succeeds or fails.

set -euo pipefail

umask 077
RUN_ROOT="$HOME/ci-untrusted"
mkdir -p "$RUN_ROOT"
chmod 700 "$RUN_ROOT"

WORK_DIR="$(mktemp -d "$RUN_ROOT/job.XXXXXX")"
cleanup() {
  chmod -R u+rwX "$WORK_DIR" 2>/dev/null || true
  rm -rf "$WORK_DIR"
}
trap cleanup EXIT INT TERM

cd "$WORK_DIR"
printf 'user=%s\nworkspace=%s\n' "$(id -un)" "$WORK_DIR"

Before running rm -rf, ensure that the path refers to the temporary directory created by the current job. Never construct it by directly appending a branch name. Branch names may contain slashes, spaces, or deliberately crafted characters. They are suitable for display, but not for unprocessed use in file paths.

After checking out the source, record the commit hash and print it in the log. The trusted path must later retrieve the code again using this immutable identifier rather than relying only on a branch name that may continue to move.

Prove the Environment Is Secretless Before Building

“Not actively using secrets” does not mean the job cannot read them. The runner service may inherit its startup environment, and Shell initialization files may add tokens. Check variable names before invoking project scripts, and pass only the required variables to the build command.

blocked='TOKEN|SECRET|PASSWORD|PRIVATE|SIGNING|AUTH|CREDENTIAL'
if env | cut -d= -f1 | grep -Eiq "$blocked"; then
  printf '%s\n' "blocked environment variable detected" >&2
  exit 70
fi

env -i \
  HOME="$HOME" \
  PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
  TMPDIR="$WORK_DIR/tmp" \
  xcodebuild \
    -workspace App.xcworkspace \
    -scheme App \
    -configuration Debug \
    -derivedDataPath "$WORK_DIR/DerivedData" \
    CODE_SIGNING_ALLOWED=NO \
    build

If the project depends on a package manager or custom tools, explicitly add reviewed tool directories to PATH instead of inheriting the full login environment. Checking variable names is only a baseline safeguard. Sensitive values may also be present in configuration files, the keychain, proxy settings, and Git credential helpers. The validation user should therefore start with a clean environment rather than having a few variables removed before every build.

Handle Dependencies and Caches

External code can write content into a cache that a trusted job later reads, creating a cache-poisoning risk. The safest approach is to separate caches by trust domain and further isolate external pull requests by repository or job number. Do not allow the validation path to write to the release user's SwiftPM, compiled module, or custom binary tool caches.

After restoring a cache, verify its ownership and permissions, and reject symbolic links that point outside the workspace. Executable tools should preferably come from a read-only baseline directory. Scripts generated by the project during a build may run only inside the current temporary workspace.

Do Not Let Artifacts Cross the Trust Boundary

A successful external PR build proves only that the commit passed under the validation conditions. It does not prove that the resulting archive is safe to release. A validation job could replace a build phase, alter output contents, or insert additional files into the archive. Application bundles, archives, and export directories produced by the secretless path must therefore never enter the trusted release job.

The trusted path should perform these steps:

  1. Receive the commit hash confirmed by the review system.
  2. Check out that commit again in a fresh workspace owned by the release user.
  3. Verify that the current commit exactly matches the approved value.
  4. Resolve dependencies again from a trusted cache or an empty cache.
  5. Run tests and create a new archive from source.
  6. Provide credentials only for the brief period when signing is required.
  7. Record the commit hash, dependency lockfile digest, and final artifact digest.

If test reports must be transferred from the validation path, transfer only non-executable data and parse it on the trusted side using a strict format. Do not transfer Shell scripts, plugins, compilation caches, or files that can be inserted directly into the application bundle.

Make Failure Cleanup and Auditing Part of Acceptance

Isolation is most likely to fail along error paths. Temporary directories, background processes, and logs may remain after a build is canceled, a process times out, or the machine restarts. The runner should check for leftovers from the previous job before every new job and terminate child processes belonging to the current job during final cleanup.

Use the following checklist before rollout:

Finally, run an active exercise: submit a test pull request whose build script attempts to read common sensitive variables, access the release directory, and write a file to a shared cache. The correct outcome is not that the script “did not find anything,” but that permissions and process design make those resources unreachable. Only then is validation of external contributions truly decoupled from the production release process.

Frequently asked questions

Is disabling code signing enough for an untrusted pull request?

No. The job must also run without secret variables, trusted credentials, shared writable caches, or access to the release account. Signing is only one exposure path.

Can the release pipeline publish an archive produced by the untrusted job?

It should not. The trusted lane should fetch the approved commit by its immutable identifier and rebuild it from source so no untrusted binary crosses the boundary.

Dedicated Physical Mac Node

Choose an always-on cloud Mac for your next Xcode build

Check the chip, memory, storage, billing cycle, and node region before starting configuration. Availability is based on the live status returned by the console.

Choose a Cloud Mac Configuration