Opt-in privileged helper for admin operations - #346
Merged
Conversation
Burrow's elevated operations run through `osascript … with administrator privileges`, which is password-only by construction: the `system.privilege.admin` right authenticates through SecurityAgent's classic mechanism, which never offers Touch ID. It also cannot be cancelled safely — killing osascript orphans the root child it spawned. This adds an opt-in `SMAppService` launch daemon as an alternative route, and leaves the osascript path in place as the fallback for anyone who declines it. The helper accepts three typed operations — scan, clean, optimize — and derives argv from the enum itself. There is no field for a path, an argument, a shell string, or an executable, so a caller that fully controls the XPC payload still cannot express "run this". Security properties, each enforced rather than documented: - Fresh authentication per root operation. The right is defined with `timeout: 0`, `shared: false`, and `allow-root: false`, so no credential survives a call, none is shared, and the root daemon cannot satisfy the right by virtue of being root. Registering the helper authorizes nothing. - The prompt is raised by the DAEMON. The client externalizes an empty, unauthenticated `AuthorizationRef`; the daemon rebuilds it and calls `AuthorizationCopyRights` with a non-empty rights set and interaction allowed. A GUI-side prompt would be cosmetic — the privileged side has to be what demands the right. - Callers are pinned by `NSXPCListener.setConnectionCodeSigningRequirement` (bundle identifier + Apple anchor + signing team), evaluated by the system against the real peer. The requirement is built at runtime from the helper's own signing information, so no team ID is hardcoded. - One authorization, one operation: operation IDs must be UUIDs and are served at most once, so a captured payload cannot be replayed. - Version skew is refused. A registered daemon outlives the app that installed it, so a build mismatch routes back to osascript instead of running as root with a stale idea of what `clean` does. - The engine is resolved relative to the helper's own executable and its signature is verified before it runs as root — never PATH, never an environment variable. Routing is a pure function: the helper is used only when the argv maps onto a typed operation, the daemon is registered and enabled, and its build matches. Anything else keeps the existing path unchanged. sign-macos-app.sh now fails closed if the helper is missing, unsigned, not hardened, or misdeclared to launchd — including a BundleProgram that points at anything other than the executable the pipeline just verified. SECURITY.md previously stated that Burrow installs no privileged helper and no XPC root service; that claim is now scoped to the default configuration and the opt-in helper's guarantees are spelled out.
The setting shelled out to `mo touchid enable/disable` to configure pam_tid for terminal `sudo`. It never affected Burrow's own admin prompts, which is the thing people expected it to do, and it did not reliably work. Removes the Settings section, the status probe, and the toggle. Nothing in the app now invokes `mo touchid`. This leaves MoleCLI.runElevated / runElevatedClassified and the one-shot PrivilegeBroker with no production caller — that path existed only for this setting. Both are annotated as unused rather than deleted here, since the streaming elevated path still shares AuthCancel and elevatedScript with them.
`MoleCLI.runElevated` / `runElevatedClassified` and the `MoleCLI.privilegeBroker` seam had no caller left once the `mo touchid` setting was removed. An unused function that takes arbitrary argv and runs it as root is not worth keeping around waiting for one, so it's gone along with the tests that drove it through the injected fake. Deliberately NOT removed: `PrivilegeBroker` and `SystemPrivilegeBroker` are still live. `Connectivity.run` (flush DNS / renew DHCP) constructs the broker directly and calls `openElevated`, so the protocol, the production witness, `ElevatedOutcome`, and `AuthCancel` all stay. Because that call site builds its own broker rather than taking an injected one, there is no seam left for the scripted fake, which is why `FakePrivilegeBroker` went with the deleted tests. Kept in PrivilegeBrokerTests: the exhaustive `AuthCancel` table (shared with the streaming runner) and the `elevatedScript` quoting/injection cases — the two-pass quoter whose output still runs as root. 831 tests, 0 failures.
The helper produced zero log lines across several runs while demonstrably alive and serving Mach requests — not even the unconditional startup notice. That made every failure indistinguishable from every other one, and the last diagnosis was inference from symptoms rather than evidence. Three changes, all diagnostic: - `helperTrace` mirrors every daemon message to stderr, which launchd redirects to /var/log/burrow-helper.log via StandardErrorPath. stderr needs no log-store query, predicate, or subsystem registration, so "wrote nothing" is now distinguishable from "never got that far". Startup is traced stage by stage for the same reason. - `HelperAuthorization.authorize` returns a Decision carrying the STAGE and the raw OSStatus instead of collapsing everything into an outcome. A malformed payload, a reference the Security framework won't rebuild, and a genuine refusal by the user were all reported as "denied"; only the last is normal. - The client logs its routing decision and the build the helper reported, so "the Clean never used the helper" and "the helper refused the Clean" stop looking identical from outside. Logged values stay decisions-only: stage names and operation names from closed enums, numeric status codes, build strings. No paths, output, or auth material. Also corrects the HelperService header, which still described a hand-rolled audit-token check; the connection gate is the system-enforced setConnectionCodeSigningRequirement. 831 tests, 0 failures.
… builds The check piped `codesign -d` straight into `grep -q`. With `set -o pipefail` in force, a non-zero exit from codesign fails the whole pipeline even when the grep matched, so a helper that genuinely carries the hardened runtime was reported as missing it — and the gate is fail-closed, so that aborts the release. Caught signing a real Developer ID build: the helper had flags=0x10000(runtime) and the gate still rejected it. Reads the signature into a variable first, so the check is about the flag and not about codesign's exit status.
Adding StandardErrorPath to the launchd plist made launchd refuse to exec the daemon: every spawn failed with EX_CONFIG (78) and the process never started. The change was made to gain observability and instead destroyed the thing being observed — and its symptom (a daemon that never runs, plus a 0-byte log file) is indistinguishable from a code-signing rejection or a bad bundle location, which is what it was mistaken for. Timeline is unambiguous: immediately before that key was added the daemon was running (runs=1, live pid, connection accepted); it has not executed once since. The daemon now opens /Library/Logs/burrow-helper.log itself. Same trail, but a path it cannot write costs diagnostics rather than the daemon. Still NOT verified: whether the authorization actually succeeds. The daemon has never reached that code, so the open question — whether a root daemon can raise an interactive AuthorizationCopyRights prompt — remains untested.
…the daemon
Measured, not inferred: with unified logging finally readable, the daemon was
reaching the authorization code and refusing every operation —
connection accepted from a verified Burrow client
operation clean not authorized
A launchd system daemon has no session to draw an authentication UI in, so
asking it to raise the prompt fails regardless of how the right is defined.
The original design was wrong about where a prompt can be raised, though right
about where the check belongs.
Switches to Apple's documented split:
app — AuthorizationCopyRights with interactionAllowed + preAuthorize.
This raises the prompt, in the user's own session, where
SecurityAgent can offer Touch ID and fall back to the password.
root — AuthorizationCopyRights WITHOUT interaction. Not a prompt: a
question about whether the reference genuinely holds the right.
The daemon still never trusts the client's word. The credential lives in the
security session rather than in the message, so a caller that skipped the
prompt produces a reference that fails the daemon's check — forging it means
forging a Security-framework credential, not editing an XPC payload. Dropping
interactionAllowed from the daemon also means a daemon that CANNOT be made to
prompt by anything that reaches it.
The cost, which is a real relaxation of the original decision: the credential
has to survive the hop to the daemon, so `timeout` moves from 0 to 10s. Inside
that window a second operation would not re-prompt. The replay guard still
serves each operation ID once and `shared: false` still keeps the credential
out of other processes, so what is open is narrow — but it is open, and
SECURITY.md and the Settings copy now say so instead of promising "every time".
Also drops ProcessType from the launchd plist. It was set to Interactive for
no reason beyond "the daemon shows a prompt" — which is no longer true, and it
was the only non-required key present while spawns were failing with EX_CONFIG.
832 tests, 0 failures.
…ever seen launchd finally said what was wrong, once the logs were readable: Could not find and/or execute program specified by service: 3: No such process: Contents/MacOS/BurrowHelper The file exists at exactly that path inside the registered app. BundleProgram is resolved relative to the bundle the system believes installed the job, and that BTM record was poisoned by repeated registrations of the same label from different bundles during testing — including one that was later deleted. Every EX_CONFIG spawn failure traces to this, not to signing, bundle location, the plist keys, or the approval state. There is no targeted way to evict one BTM record (`sfltool resetbtm` wipes every app's background items), so the label moves to dev.caezium.Burrow.privileged-helper, which BTM has no record of. The label has never shipped, so renaming costs nothing. Also fixes the release gate's MachServices check, whose plutil key path still escaped the old name and would have passed a plist that vends nothing. Note for the future: this failure mode is specific to re-registering one label from several bundles. A shipping install registers once from one app.
…lizes it The daemon reported: NOT authorized: createFromExternalForm status=-60005 outcome=denied Failing at AuthorizationCreateFromExternalForm, not AuthorizationCopyRights — the reference could not be rebuilt at all, so no right was ever evaluated. The externalized form is not a self-contained token. It is a handle to an authorization instance living in the Security Server, and that instance exists only while the creating process holds its AuthorizationRef. `authenticate()` freed the ref in a `defer`, so it was gone before the XPC message left the app, and the daemon was handed a handle to nothing. The failure surfaces as errAuthorizationDenied, which is the same code a genuine refusal produces — so it reads as "the user was denied" and points at the authorization policy rather than at object lifetime. ClientAuthorization is now a class that owns the ref and frees it in deinit, and the client wraps the round trip in `withExtendedLifetime`. Ordinary scoping is not sufficient: the optimizer is free to release earlier. Confirmed against trilemma-dev/Blessed discussion #3, which documents the same symptom and cause. 832 tests, 0 failures.
With authorization finally working end to end — authorized: copyRights status=0 outcome=granted engine unavailable or failed signature verification — the run still did nothing, and the GUI reported "Done — caches cleared". Three separate defects behind that: 1. The engine integrity check could never pass. It ran SecStaticCodeCheckValidity against Contents/Resources/engine/mole, which is a bash script: `codesign` reports "code object is not signed at all" for it, and it sources a whole lib/ directory that would each need checking too. Now validates the containing APP BUNDLE instead, whose signature seals Contents/Resources — so one check certifies the engine, every library it sources, and that all of it came from our signing team. Achievable, and strictly stronger than what was attempted. 2. A failed helper run rendered as success. `.launchFailed` yielded a bare exit 127 with an empty transcript, and the report parser reduces empty output to "Done — caches cleared" — a tool that deletes files reporting a successful clean when nothing ran. It now emits an explanatory line so the transcript is non-empty and the failure is visible. 3. testTrustedExecutable_onlyEverReturnsKnownLocations asserted against a stale list of three `mo` paths. `trustedExecutable()` returns the BUNDLED engine first, plus two burrow-engine paths. The test only ever passed because dev and CI checkouts had no bundled engine — the configuration every release actually ships would have failed it. Rewritten to assert the real invariant: bundle-relative or fixed absolute path, never PATH. 832 tests, 0 failures.
…elper
Clean and optimize worked; everything else elevated still took the old
password-only osascript path. Now all of it goes through the helper.
Added:
optimizeScan the elevated `optimize --dry-run` preview, which near-missed
the recogniser and silently fell back while the equivalent
clean preview got Touch ID
flushDNS dscacheutil -flushcache, then killall -HUP mDNSResponder
renewDHCP ipconfig set <interface> DHCP
These are the first operations that run something other than the bundled
engine, so execution is now modelled explicitly: an operation resolves to an
ordered list of typed steps, each naming either the bundled engine or one of
exactly three system tools by absolute path. The daemon re-checks that path
against the closed set at the moment of spawn, not only where the step was
built.
This is stricter than the code it replaces. `Connectivity.run` elevated
`/bin/sh -c "dscacheutil -flushcache; killall -HUP mDNSResponder"` — a command
string parsed by a root shell. The two commands are now two separate spawns
with fixed argv and no shell anywhere.
The interface name for renewDHCP is the ONLY caller-supplied value that
reaches an argv. It is validated twice: a strict BSD-name shape (letters then
digits, nothing else), and membership of the machine's real interface list
from getifaddrs. A well-formed name for an interface that doesn't exist is
refused, as is an interface supplied for an operation that takes none.
Connectivity keeps the osascript path as its fallback for anyone without the
helper installed, matching how the streaming operations already behave.
843 tests, 0 failures.
`StartupInventory.scanLiveIncludingLoginItems` spawned `/usr/bin/sfltool dumpbtm` as the user. sfltool then raises its OWN authentication dialog — an unexplained "sfltool wants to make changes" prompt, attributed to sfltool rather than Burrow — and still returns only a partial list, which the existing code comment already acknowledged. Through the helper it is one authentication the user recognises, and the complete BTM database. Without the helper the previous behaviour is kept exactly, prompt included, rather than losing the Login Items list. Adds `/usr/bin/sfltool` to the closed executable set (now four system tools beside the bundled engine) and a `readLoginItems` operation with fixed argv. Not addressed, because it is not an elevation: the "Burrow would like to access data from other apps" dialog is macOS's TCC consent for kTCCServiceSystemPolicyAppBundles, raised because the folder scan reads other apps' containers as the user. No helper can remove it — Full Disk Access or the existing elevated scan (root bypasses TCC) are the two answers, and both already exist. 843 tests, 0 failures.
project.yml spells the version twice — once for the app, once for the helper target — and Xcode cannot derive one from the other. The helper reports its own build over XPC and the client refuses a mismatch, which is deliberate: a registered daemon outlives the app that installed it. The cost is a silent failure mode. Bump the app for a release, forget the helper, and nothing breaks loudly: the helper is simply never used again, every user falls back to the password-only prompt, and the feature dies quietly. Invisible in the build, the tests, and the artifact. Runs in CI via the existing scripts/tests discovery.
Bumps app and helper to 0.12.0 build 24 (both, enforced by the new version-sync test) and writes the release notes. Also fixes a signing failure this feature introduced, which would have failed the tag workflow: sign-macos-app.sh signed every Mach-O under Contents/, including the bundle's own main executable. Signing that one individually makes codesign treat it as the bundle and validate the bundle's nested code — which now fails with "code object is not signed at all / In subcomponent: …/BurrowHelper" whenever `find` reaches the main executable before the helper. Previous releases never hit it because Contents/MacOS held exactly one executable. The outer seal signs it correctly as part of the bundle, so it is excluded from the per-file loop. The existing signer tests passed only because directory order happened to be favourable, so the guard added here asserts the invariant directly: the main executable is never passed to codesign on its own. Verified: Release configuration builds and signs cleanly in both ad-hoc and Developer ID modes; 843 Swift tests and 53 release-safety tests pass.
caezium
force-pushed
the
feat/privileged-helper
branch
from
August 8, 2026 08:14
9b122e3 to
531a7bb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces password-only
osascriptelevation with an opt-in, Developer ID-signed launch daemon, so Clean/Optimize/scan can authenticate with Touch ID. The osascript path stays exactly as it is and remains the fallback for anyone who declines the helper — declining costs the Touch ID prompt and safe cancellation, never the authentication itself.Do not merge yet. The runtime behaviour that can only be checked on a signed build is listed at the bottom.
Why
system.privilege.adminauthenticates through SecurityAgent's classic mechanism, which never offers Touch ID — the existing code comment inMoleCLI.elevatedScriptalready says so. The same path also can't be cancelled: terminatingosascriptorphans the root child it spawned, which is whyOperationFlowhas no safe cancel for elevated runs today.Scope of what the helper can do
Three typed operations —
scan,clean,optimize— and the daemon derives argv from the enum itself:These are exactly what
CleanView/OptimizeView/TuneUpViewpass today, so the helper changes how a command is elevated, never what runs. The request type has three fields — operation, operation ID, client build — and no field for a path, an argument, a shell string, or an executable. A caller that fully controls the XPC payload still cannot express "run this".Security properties, and where each is enforced
timeout: 0,shared: falseallow-root: falseAuthorizationCopyRightswith a non-empty rights set +interactionAllowed; client never pre-authorizesNSXPCListener.setConnectionCodeSigningRequirement— identifier + Apple anchor + signing teamHelperReplayGuard)PATH/env droppedTwo details worth calling out because both are one-line ways to make the whole thing decorative, and both are covered by tests:
LAContextin the app, then send the request — is not an authorization at all: a caller that skipped the prompt would be indistinguishable from one that passed it. The client externalizes an empty, unauthenticatedAuthorizationRefand the root side is what demands the right.connection.processIdentifierand verifying that process is the classic vulnerable pattern (PIDs get recycled and raced). Doing it properly needs the audit token, and reaching anNSXPCConnection's audit token means private API — not a dependency worth taking in a root daemon. The macOS 13 requirement API is supported and evaluated in the kernel against the real peer.Routing
PrivilegeRoute.decideis pure. The helper is used only when the argv maps onto a typed operation and the daemon is registered and enabled and the build matches. Every other combination returns.osascript.PrivilegeRouteTestswalks the full cross-product so no future edit can produce a.helperroute from a state that isn't fully green.Release gate
sign-macos-app.shnow fails closed if the helper is missing, not Mach-O, unsigned, not hardened, on a different team, or misdeclared to launchd — including aBundleProgrampointing at anything other than the executable the pipeline just verified, which is the one that would put an unvetted binary behind root. The gate is mandatory, so "no helper" is an error rather than a skip; the existing signer test fixtures were updated to stage one rather than weakening the check.Docs
SECURITY.md said "Burrow installs no privileged/background helper and no XPC root service." That is now scoped to the default configuration, with the opt-in helper's guarantees spelled out. README's equivalent claim updated too.
Verification done
HelperContractTests,HelperAuthorizationTests,HelperCodeRequirementTests,PrivilegeRouteTests).Contents/MacOS/BurrowHelper, plist atContents/Library/LaunchDaemons/,CFBundleVersionbaked into the binary, strict signature verification passes.Still needs an on-device check before merge
None of this can be exercised without a Developer ID build and a real admin approval:
AuthorizationCopyRightswithinteractionAllowed, called from a root daemon against a client-supplied external form, is the documented pattern — but that the prompt appears correctly in the user's session, and thattimeout: 0doesn't make it re-prompt mid-operation, needs to be seen.class: user/group: adminright, the security properties are unchanged but the headline benefit isn't there, and the rule needs revisiting.LAPolicy.deviceOwnerAuthenticationaccepts a paired Watch; SecurityAgent does not. That is a deviation from the decision as originally framed — the Touch-ID-then-password behaviour is preserved, the Watch is not.SMAppService.register()approval flow and removal via Login Items & Extensions.