Engineering Article

Diagnosing and Safely Resetting macOS TCC Permissions in Cloud Mac CI

Diagnosing and Safely Resetting macOS TCC Permissions in Cloud Mac CI

The same UI automation command may work when run manually over SSH yet fail to capture screenshots, click windows, or send events to another application when executed by a CI runner. Rerunning the job usually does not help, because the failure lies not in the script logic but in macOS Transparency, Consent, and Control, or TCC. TCC evaluates more than which user is running the command: it also considers which graphical session the command belongs to, which process is responsible for it, and whether that process’s code identity has changed.

Identify Whether the Failure Is TCC-Related

TCC issues are often mistaken for driver failures, applications that did not launch, or command timeouts. Start by separating the job into two layers: command-line-only steps and steps that require graphical permissions. If compilation, repository access, and ordinary network requests work while screenshots, accessibility control, or automation events fail independently, then TCC is worth investigating.

First, record the execution context:

id
whoami
printf 'console_user=%s
' "$(stat -f '%Su' /dev/console)"
printf 'uid=%s
' "$(id -u)"
launchctl print "gui/$(id -u)" >/tmp/gui-domain.txt 2>&1
ps -axo user,pid,ppid,command | grep -E 'runner|xcodebuild|osascript' | grep -v grep

If the user reported by /dev/console is not the runner user, or if gui/<uid> does not exist at all, the job has no usable graphical login domain. Repeatedly resetting permissions is pointless in this situation; first correct where and how the job is launched.

“It works over SSH” proves only that the shell, paths, and file permissions are available. It does not prove that the process has Screen Recording, Accessibility, or application automation permissions.

Confirm the Denial in System Logs

Immediately after reproducing the failure, query a short log window to avoid being overwhelmed by unrelated entries:

log show --last 5m \
  --predicate 'subsystem == "com.apple.TCC"' \
  --style compact

Pay close attention to the requested service, client path, responsible process, and denial result. Do not search only for the script name. Shell scripts are often launched by a terminal, runner, osascript, or test host, and the process that actually requires authorization may be the upstream process hosting them.

Establish a Four-Part Identity Baseline

Every cloud Mac should retain a permission baseline that contains no secrets and includes at least the execution user, launch domain, canonical executable path, and code-signing identity. After upgrading the runner or replacing its binary, compare the new state with this baseline.

RUNNER="/opt/ci/bin/runner"

ls -l "$RUNNER"
realpath "$RUNNER"
codesign -dv --verbose=4 "$RUNNER" 2>&1
codesign -dr - "$RUNNER" 2>&1
spctl --assess --type execute --verbose=4 "$RUNNER"

An unchanged path does not imply an unchanged identity. Replacing a file in place, temporarily downloading an unsigned tool, or pointing the same symbolic link to different versions can all change the client identity seen by TCC. A safer approach is to place each version in its own directory, update the entry point through a controlled switch, and rerun the signature checks after every change.

You must also identify the “responsible process.” For example, a runner may launch a shell, which then launches osascript, which finally controls a graphical application. In that chain, the authorization target may not be the script stored in the repository. During troubleshooting, trace the process tree upward through PPIDs instead of broadly granting permissions to multiple unrelated tools.

Run the Job in the Correct Graphical Session

Jobs that need to interact with the desktop should not run as LaunchDaemons. A LaunchDaemon belongs to the system domain; even when configured to run as a specific user, it does not enter that user’s Aqua session. A better approach is to register the runner as a LaunchAgent for the target user and load it after the graphical login session has started.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.example.ci-runner</string>
  <key>ProgramArguments</key>
  <array>
    <string>/opt/ci/bin/runner</string>
    <string>run</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
  <key>LimitLoadToSessionType</key>
  <string>Aqua</string>
  <key>StandardOutPath</key>
  <string>/Users/ci/Library/Logs/ci-runner.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/ci/Library/Logs/ci-runner-error.log</string>
</dict>
</plist>

Validate the file with plutil -lint, then load it as the target user:

plutil -lint "$HOME/Library/LaunchAgents/com.example.ci-runner.plist"
launchctl bootstrap "gui/$(id -u)" \
  "$HOME/Library/LaunchAgents/com.example.ci-runner.plist"
launchctl print "gui/$(id -u)/com.example.ci-runner"

If bootstrap reports that the domain cannot be found, first confirm that the user has an active graphical session. Do not force the job into another user’s session, and do not rely on a one-off sudo invocation to conceal an ownership or session-assignment error.

Reset Only the Permissions You Actually Need

Consider resetting permissions only after confirming that the user, session, and process identity are all correct. Stop the runner first so that it does not continue making requests during the reset and create additional confusion. Then have the affected user reset only the relevant services:

launchctl bootout \
  "gui/$(id -u)/com.example.ci-runner"

tccutil reset Accessibility
tccutil reset ScreenCapture
tccutil reset AppleEvents

Do not treat these three commands as a standard bundle. If the job needs only screen recording, reset only ScreenCapture. Address Accessibility only when the job must control the interface, and AppleEvents only when it must send automation events to a specific application. After the reset, trigger one minimal test in a valid graphical session, complete the required authorization, and then restart the runner.

After Screen Recording permissions change, an existing process may still retain its previous permission state. Fully quit and restart the responsible process instead of merely rerunning the repository script. Automation permissions may also be stored separately for each target application, so the ability to control application A does not imply permission to control application B.

Deleting the TCC database from the user directory or modifying its SQLite records directly is not a reliable repair method. Doing so clears decisions for more applications and makes the recovery process difficult to audit. OrbVPS cloud Macs are dedicated physical nodes, but dedicated hardware does not alter the macOS permission model. Least-privilege authorization must still be applied to the actual job and responsible process.

Make Recovery Part of the Acceptance Checklist

Do not immediately return the node to the full pipeline after recovery. First run a probe that performs only one screenshot or one automation event, then restore test workloads gradually. Record the following results in the node acceptance checklist:

  1. The runner user matches the console user.
  2. The gui/<uid> domain exists, and the LaunchAgent status is running.
  3. The runner’s canonical path matches the expected version.
  4. The codesign output matches the baseline.
  5. TCC logs no longer show denials for the target service.
  6. The minimal probe still passes after the runner restarts.
  7. No test hosts or automation processes remain after the full job finishes.

Finally, simulate recovery after a node restart. If the graphical session has not yet been established, the runner should remain unavailable for jobs rather than accepting work and then stalling during screenshot capture or window control. Add a session check at the job entry point so that it prints a clear diagnostic and exits when the required session is unavailable:

uid="$(id -u)"
if ! launchctl print "gui/$uid" >/dev/null 2>&1; then
  printf '%s
' "No active GUI session for CI user"
  exit 75
fi

The hardest part of a TCC failure is not clicking the authorization button, but determining which identity should receive the authorization. Once the execution user, LaunchAgent domain, executable path, and signing identity are fixed, permission failures can be transformed from intermittent incidents into a set of verifiable and reversible operating conditions.

Frequently asked questions

Why does a command work over SSH but fail with a TCC denial in the CI service?

The two runs may belong to different login sessions, launchd domains, or responsible processes. TCC does not transfer authorization merely because both executions use the same script path.

Should I delete the TCC database to repair permissions?

No. Stop the affected jobs, run tccutil reset for only the required service as the affected user, and grant permission again from an active graphical session.

OrbVPS Cloud Mac

Run builds on dedicated Apple Silicon physical nodes

Configure nodes by model, region, and rental period. Actual availability is confirmed in real time by the control panel.

Rent a Cloud Mac now