Skip to content

Opt-in privileged helper for admin operations - #346

Merged
caezium merged 14 commits into
mainfrom
feat/privileged-helper
Aug 8, 2026
Merged

Opt-in privileged helper for admin operations#346
caezium merged 14 commits into
mainfrom
feat/privileged-helper

Conversation

@caezium

@caezium caezium commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Replaces password-only osascript elevation 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.admin authenticates through SecurityAgent's classic mechanism, which never offers Touch ID — the existing code comment in MoleCLI.elevatedScript already says so. The same path also can't be cancelled: terminating osascript orphans the root child it spawned, which is why OperationFlow has 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:

scan     → ["clean", "--dry-run"]
clean    → ["clean"]
optimize → ["optimize"]

These are exactly what CleanView/OptimizeView/TuneUpView pass 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

Property Enforced by
Fresh auth per root operation; registering authorizes nothing Right defined with timeout: 0, shared: false
Root daemon can't authorize itself allow-root: false
The prompt is real, not cosmetic Daemon calls AuthorizationCopyRights with a non-empty rights set + interactionAllowed; client never pre-authorizes
Only Burrow can connect NSXPCListener.setConnectionCodeSigningRequirement — identifier + Apple anchor + signing team
One authorization, one operation UUID operation IDs, served at most once (HelperReplayGuard)
A stale root daemon never runs Build-skew check routes back to osascript
Root never runs an untrusted binary Engine resolved relative to the helper's own executable, signature verified before exec, PATH/env dropped

Two details worth calling out because both are one-line ways to make the whole thing decorative, and both are covered by tests:

  • The prompt is raised by the daemon, not the GUI. The obvious design — prompt with LAContext in 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, unauthenticated AuthorizationRef and the root side is what demands the right.
  • Caller identity comes from the system, not a PID lookup. Reading connection.processIdentifier and verifying that process is the classic vulnerable pattern (PIDs get recycled and raced). Doing it properly needs the audit token, and reaching an NSXPCConnection'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.decide is 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. PrivilegeRouteTests walks the full cross-product so no future edit can produce a .helper route from a state that isn't fully green.

Release gate

sign-macos-app.sh now fails closed if the helper is missing, not Mach-O, unsigned, not hardened, on a different team, or misdeclared to launchd — including a BundleProgram pointing 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

  • 835 Swift tests, 0 failures, 1 pre-existing skip — including 4 new suites (HelperContractTests, HelperAuthorizationTests, HelperCodeRequirementTests, PrivilegeRouteTests).
  • 17 signer script tests including 7 new privileged-helper gate cases.
  • Local Debug build: helper staged at Contents/MacOS/BurrowHelper, plist at Contents/Library/LaunchDaemons/, CFBundleVersion baked 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:

  1. Daemon-side interactive authorization. AuthorizationCopyRights with interactionAllowed, 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 that timeout: 0 doesn't make it re-prompt mid-operation, needs to be seen.
  2. Whether SecurityAgent actually offers Touch ID for this right. Touch ID presentation is SecurityAgent's decision, not something the app requests. The guaranteed floor is the login password; if Touch ID doesn't appear for a custom class: user / group: admin right, the security properties are unchanged but the headline benefit isn't there, and the rule needs revisiting.
  3. Apple Watch. LAPolicy.deviceOwnerAuthentication accepts 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.
  4. SMAppService.register() approval flow and removal via Login Items & Extensions.

caezium added 14 commits August 8, 2026 01:09
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
caezium force-pushed the feat/privileged-helper branch from 9b122e3 to 531a7bb Compare August 8, 2026 08:14
@caezium
caezium merged commit adf9f89 into main Aug 8, 2026
4 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