Skip to content

Bug 2073303 - grant Screen Recording during provisioning - #76

Merged
rcurranmoz merged 2 commits into
mainfrom
bug-2073303-screencapture-grant
Sep 21, 2026
Merged

rcurranmoz merged 2 commits into
mainfrom
bug-2073303-screencapture-grant

Conversation

@rcurranmoz

Copy link
Copy Markdown
Collaborator

Why this, and why here

EACS re-enables SIP and wipes TCC, so a reprovisioned host comes back without the ScreenCapture grant and silently fails every getDisplayMedia() call with SCStreamErrorUserDeclined (-3801) for the rest of its life.

The only symptom is an intermittent orange on a random-looking subset of the pool. 42 of the 174 hosts in gecko-t-osx-1500-m4 were in that state — which is why bug 1937556 reached 499 failures in 30 days and was sheriff-escalated before anyone established it was the hosts, not Gecko.

Those 42 have now been granted by hand and verified: 0 occurrences of -3801 across a 40-run try push. Without this step, that fix decays one host at a time, invisibly, as machines go through refresh waves.

It belongs in this repo rather than ronin for two reasons:

  • The approval needs an administrator-authenticated click in System Settings. Puppet runs in the worker-start path and has no credential for it; this repo already handles the admin password for SecureToken minting and BST escrow.
  • The lifecycle is exactly ours — the grant is destroyed by the wipe we perform, and has to be re-applied after the bootstrap, once the worker binaries exist and cltbld owns the console session.

ronin gets the detector half: ronin_puppet#1412.

Why GUI automation rather than something sane

MDM cannot supply this grant. Apple permits only AllowStandardUserToSetSystemService for kTCCServiceScreenCapture in a PPPC payload, which authorises a standard user to approve it rather than approving it — and an approval made while such a profile is installed is recorded as MDM-managed (flags 12) and then ignored by TCC. It looks correct in System Settings and in the access table, and does not work.

There is no consent dialog to automate either:

tccd: Service kTCCServiceScreenCapture does not allow prompting; returning denied

Driving the Screen Recording pane is the only remaining route.

Preconditions the payload enforces

Each of these was learned by getting it wrong on the fleet:

Precondition Why
SIP on SIP-off hosts are already covered by macos_tcc_perms
Dev-ID-signed worker binaries ad-hoc is Identifier=a.out, no TeamIdentifier — grant is stored and ignored
No ScreenCapture PPPC override otherwise the row lands flags=12 and is inert
Host idle a running test owns the GUI session: the click doesn't land, and opening System Settings can corrupt that test — 16 of 18 first-pass failures were mid-mochitest

Failure semantics

Exit 3 means "not applicable / not now" (SIP off, busy, no console session) and is reported, not raised — a host that is merely busy must not abort a reprovision.

A genuine failure — unsigned binary, override present, flags=12, or a row that never reached auth_value 2 — raises. Handing back a host that cannot screen-capture and saying nothing is the failure mode this entire bug is made of.

Credential handling

Substituted from the vault at fire time and written over ssh, same as _os_upgrade_script, so it never sits in SimpleMDM. On the host it goes into a 0600 file owned by cltbld, read once by the AppleScript and deleted immediately — never an argv, never in the environment. The staged script is removed afterwards (asserted in tests).

Testing

  • 12 new tests covering the three-way exit-code split, credential substitution, payload cleanup, and a regression guard that the step stays in both reprovision() and provision().
  • test_mint.py sequence assertions extended to require the grant runs after the bootstrap sentinel.
  • Full suite: 285 passed. ruff check and ruff format clean.
  • The embedded AppleScript compiles (osacompile), and the payload passes shellcheck.
  • The approval logic itself is the one proven on the fleet today across all 42 hosts.

🤖 Generated with Claude Code

EACS re-enables SIP and wipes TCC, so a reprovisioned host comes back without the
ScreenCapture grant and silently fails every getDisplayMedia() call with
SCStreamErrorUserDeclined (-3801) for the rest of its life. The only symptom is an
intermittent orange on a random-looking subset of the pool: 42 of the 174 hosts in
gecko-t-osx-1500-m4 were in that state, which is why bug 1937556 reached 499
failures in 30 days and was sheriff-escalated before anyone established it was the
hosts and not Gecko.

Those 42 have been granted by hand and verified (0 occurrences of -3801 across a
40-run try push). Without this step that fix decays one host at a time, invisibly,
as machines go through refresh waves.

This belongs here rather than in ronin for two reasons. The approval needs an
administrator-authenticated click in System Settings and puppet has no credential
for it; and the lifecycle is exactly this one -- the grant is destroyed by the wipe
we perform, and has to be re-applied after the bootstrap, once the worker binaries
exist and cltbld owns the console session. ronin gets the detector half instead
(macos_screencapture_check, ronin_puppet#1412).

MDM cannot supply the grant. Apple permits only AllowStandardUserToSetSystemService
for this service in a PPPC payload, which authorises a standard user to approve it
rather than approving it -- and an approval made while such a profile is installed
is recorded as MDM-managed (flags 12) and then ignored by TCC. There is no consent
dialog to automate either: tccd logs "Service kTCCServiceScreenCapture does not
allow prompting; returning denied". Driving the Screen Recording pane is the only
route, which is why this is GUI automation rather than a defaults write.

The payload enforces four preconditions, each of which was learned by getting it
wrong on the fleet:

- SIP on. SIP-off hosts are already covered by macos_tcc_perms.
- Developer-ID-signed worker binaries. An ad-hoc binary is Identifier=a.out with no
  TeamIdentifier and cannot satisfy a code requirement, so the grant is stored and
  ignored.
- No ScreenCapture PPPC override, or the resulting row is flags=12 and inert.
- Host idle. A running test owns the GUI session: the click does not land, and
  opening System Settings can corrupt that test. 16 of 18 failures on the first
  fleet pass were mid-mochitest.

Exit 3 from the payload means "not applicable / not now" and is reported rather
than raised, so a host that is merely busy does not abort a reprovision. A genuine
failure -- unsigned binary, override present, flags=12, or a row that never reached
auth_value 2 -- raises, because handing back a host that cannot screen-capture and
saying nothing is the failure mode this whole bug is made of.

The credential is substituted from the vault at fire time and written over ssh,
same as _os_upgrade_script, so it never sits in SimpleMDM. On the host it goes into
a 0600 file owned by cltbld, read once by the AppleScript and deleted immediately,
so it is never an argv and never in the environment. The staged script is removed
afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rcurranmoz
rcurranmoz requested a review from a team as a code owner September 21, 2026 18:16
CI caught what the local run could not. tests/test_provision.py patches the step
list but did not know about step_screencapture_grant, so the real step ran and
tried to resolve the admin password from 1Password. That passes on a laptop with
`op` installed and authenticated, and fails on a runner without it -- a false green
of exactly the kind this suite exists to prevent.

Mocked in all five provision() sequence tests, and the ordering test now asserts
the grant runs last, after the bootstrap sentinel: it needs the worker binaries in
place and cltbld owning the console session, neither of which exists before puppet
has run.

Verified by reproducing the CI condition locally -- running the suite with the
1Password CLI removed from PATH. 285 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rcurranmoz
rcurranmoz merged commit 07e145e into main Sep 21, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant