cloudfox-coreshift-core 2.8.1

Low-level Linux and Android systems primitives for CoreShift (CloudFox)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
# Exec-over-Binder — how the daemon talks to the app: dilemma & design space

**Status:** **IN PROGRESS** — design space exploration for the Binder backend that
lets `observerd` (a shell-uid `app_process` daemon) hand its privileged exec
service to a foreground app over Android's Binder IPC instead of an abstract
socket. The mechanism is grounded in the Shizuku reference implementation and
verified against the live ROM's `framework.jar` (Android 14 / SDK 34) and the
AOSP android-14.0.0_r1 sources. Revision 4 adds §3.8, a comparison against the
shipped AxManager analogue (a Shizuku fork) confirming the §3.5/§3.6 wire shape
in production and giving an honest better/worse efficiency assessment against
it. Revision 3 resolved §5 to a pinned-uid per-transaction gate (2B), added a
dedup gate to §6, and completed §3.6.1 with the fully field-by-field
`ApplicationInfo` walk (including the two Parcelling builtins) so the §4
fragility verdict is backed by the exact layout. The dilemmas below are the
remaining open design decisions, and the last section records what has been
decided so far.

**Scope:** how the daemon process (uid 2000) communicates with the app so the app
can request privileged exec. This document is the "complete picture": the exact
Binder mechanism, every wire-level layout we must reproduce byte-for-byte, the
user-set design constraints (no UID machinery, env-var package whitelist, sender
driven by the existing fg resolution via mailbox), and the open problems where a
solution is still needed. Implementation of the decided path is out of scope of
this document.

**Decisions at a glance:**

1. Transport is **Binder, not the abstract socket** `@coreshift_exec`. The app
   cannot reach the daemon's socket (AuthPolicy denies non-root/shell uids), and
   the direction is server→app: the daemon must *push* a service binder into the
   app so the app can call back.
2. The handoff uses the only sanctioned app↔shell IPC channel: an **exported
   ContentProvider** in the app. The daemon resolves it with
    `IActivityManager.getContentProviderExternal`, then calls
    `IContentProvider.call("sendBinder", …)` passing its service binder, and
    finally releases it with `removeContentProviderExternalAsUser`.
3. **No observer machinery.** No `registerProcessObserver`/`registerUidObserver`,
   no `getPackagesForUid`, no per-call runtime permission check. The sender is
   driven by the existing foreground-resolution mailbox, not subscriptions. A
   stateless `AIBinder_getCallingUid()` read inside `on_transact` is **not** the
   same cost and is kept (see §5).
4. **Authorization is the fg resolution + an env-var whitelist + a pinned-uid
   per-transaction check.** The sender only hands the binder to a package whose
   name is in the `CORESHIFT_EXEC_*` env whitelist; at handoff it pins that
   package's uid once (from the provider's `ApplicationInfo` during the §3.6
   walk — no extra IPC), and `on_transact` rejects any caller whose uid is not
   the pinned one. This closes the binder-proxy-leak vector without the observer
   machinery Dilemma 3 rules out. No app-permission parsing.
5. The target wire is **Android 14 (SDK 34)**; tx codes and parcel layouts below
   were resolved against `/mnt/shared/exp/framework.jar` and the AOSP
   android-14.0.0_r1 sources. Non-Android builds keep the existing stubs.

---

## 1. The hard constraints (inherited, not negotiable)

- **The daemon is `observerd`, one binary, feature threads.** It runs today as
  uid 2000 (shell) via `app_process`, hosting the abstract sockets
  `@coreshift_fg` / `@coreshift_fps` / `@coreshift_exec`. The Binder backend must
  be a thread (or threads) inside this same process; no helper binary.
- **Core owns Binder.** All Binder work lives in `coreshift_core`
  (`src/binder/mod.rs`), android-gated behind `#[cfg(target_os = "android")]`,
  with non-Android stubs. The daemon consumes the crate's API.
- **Reuse the existing fg resolution.** The foreground package name is already
  produced by the fg channel and delivered through the crate's `Mailbox`
  pattern. The sender must consume that, not invent a second source of truth.
- **No client-supplied identity or privilege.** The whitelist is daemon-side
  policy from the environment, never from the wire.
- **Full lifecycle duty:** handoff happens on fg change, must be repeatable, must
  not leak providers, must be testable without an Android device for the parts
  that can be (parcel builds, tx resolution); on-device verification is a
  documented requirement.
- **Do not modify unrelated FPS/task work.**

## 2. Why this is a dilemma at all: the app and the daemon are on opposite sides

`@coreshift_exec` (FocusSource, `EXEC-PRIVILEGED-EXECUTION-DILEMMA.md`) is a
root/shell-privileged command runner. Its boundary is `SO_PEERCRED` + a UID
allowlist. That works for *shell tools* that connect out. The new requirement is
different in kind: **a normal sandboxed app** must be able to ask for privileged
execution. The app is uid 10000+ in its own SELinux domain:

- It **cannot connect** to `@coreshift_exec` — even if the kernel allows the
  abstract-socket connect, the daemon's AuthPolicy denies any uid other than
  0/2000, and (on device) SELinux blocks the connect before that.
- Even if it could, the app has no way to *receive* anything from the daemon —
  no listening socket of its own that a shell process is allowed to reach, no
  callback channel.
- So the communication is not "app connects to daemon" but **"daemon pushes a
  Binder proxy into the app"**, and afterwards the app uses normal
  `Binder.transact` against that proxy.

That "push" is the crux. Android has exactly one sanctioned, uid-agnostic way for
an arbitrary process to obtain an arbitrary app's service object: an **exported
ContentProvider**. This is precisely the trick Shizuku uses, and it is what the
rest of this document walks through.

## 3. The complete picture (the mechanism, grounded in sources)

### 3.1 Actors

| Actor | Who | Role |
|---|---|---|
| **daemon / server** | `observerd` via `app_process`, uid 2000, SELinux `untrusted_app`-adjacent shell domain | hosts the exec `BinderService` (an `AIBinder_Class_define` object + thread pool), runs `BinderSender` |
| **app / client** | a foreground sandboxed app | hosts an **exported ContentProvider** (authority `<pkg>.shizuku`-style), receives the server binder, then `Binder.transact`s it |
| **system_server** | uid 1000 (system) | mediates provider resolution via `ActivityManager`; the only process that can start an app's provider and hand out its binder |

### 3.2 The sequence (daemon → app)

```
 ┌─────────── daemon (uid 2000) ───────────┐          ┌───── app (sandbox) ─────┐
 │ BinderService (exec)  ←←←←←←←←←←←←←←←←←←←←←←←←←←←←← Binder.transact() ─────┐│
 │   ▲                                      │          │         ▲               ││
 │   │ on fg pkg change (mailbox)           │          │         │ stores binder ││
 │ BinderSender:                            │          │ IContentProvider.call( ││
 │   1. env whitelist check (pkg)           │          │   "sendBinder",         ││
 │   2. getContentProviderExternal(auth) ──►│──────┐   │    arg, extras{binder}) ││
 │   3. parse ContentProviderHolder         │      │   │   ←───────────────────┘│
 │      ─► extract provider binder          │      │   │ (app's exported provider)│
 │   4. call("sendBinder", bundle{binder}) ─►│─────┼──►│                          │
 │   5. removeContentProviderExternalAsUser(auth, userId) │      │   │          │
 └──────────────────────────────────────────┘      ▼   └──────────────────────────┘
                                            system_server
                                    (AM.getContentProviderExternal)
```

1. The daemon learns the foreground package via the **existing fg resolution**
   (mailbox), not a UID observer.
2. If the package is on the **env-var whitelist**, the sender resolves the app's
   exported provider authority `<pkg>.shizuku` through
   `ActivityManager.getContentProviderExternal`.
3. system_server starts the app if needed, finds the exported provider, and
   returns a `ContentProviderHolder` parcel whose fields the daemon parses to
   pull out the `IContentProvider` binder.
4. The daemon calls `IContentProvider.call("sendBinder", …)` on that binder,
   passing its own **exec service binder** inside a `Bundle`.
5. The app's `call()` implementation reads the binder from the bundle, stores it,
    and from then on transacts it directly. The daemon releases the provider with
    `removeContentProviderExternalAsUser`; the app keeps the service proxy.

### 3.3 The BinderService the daemon hosts

Reuses the existing NDK Binder machinery in `src/binder/mod.rs`
(`AIBinder_Class_define` + `transact_write` + thread pool
`set_thread_pool_max(0)` / `join_thread_pool`). The service object is a normal
exec-dispatch object; its `on_transact` runs whatever command the app requests.
The app's calls arrive on binder thread-pool threads; exec dispatch itself stays
on the existing exec-channel reactor via the mailbox.

### 3.4 Wire level 1 — `getContentProviderExternal` and the holder reply

Resolved tx code against the **live** `/mnt/shared/exp/framework.jar`:
`IActivityManager.getContentProviderExternal` = **129**,
`removeContentProviderExternalAsUser` (the live Android 14 release method;
the plain `removeContentProviderExternal` is deprecated there and takes an
`IBinder` token, not a user id) = resolved fresh via
`TRANSACTION_removeContentProviderExternalAsUser`,
`getContentProvider` = **30**,
`registerProcessObserver` = **121**, `getFocusedRootTaskInfo` = **165**.
(`IContentProvider$Stub` constants do not exist in framework.jar — the
`call` tx is a stable compile-time constant = 21, see §3.5.)

Request (Java `Parcel.writeInterfaceToken` + args, order per AIDL):
`IActivityManager` token, `writeString(name /* authority */)`,
`writeInt(userId)`, `writeStrongBinder(token /* null */)`,
`writeString(callingTag /* name again */)`.

Reply (server → daemon): `writeNoException()` then the holder as
`writeTypedObject(holder)`, i.e. `writeInt(1)` marker + `holder.writeToParcel`.
On Android 14 the `ContentProviderHolder` is in **`android.app`** and its parcel
is (`ContentProviderHolder.java:58-68`), in this exact order:

```
info.writeToParcel(out)          // ProviderInfo — see §3.5, the painful part
out.writeStrongBinder(provider)  // ◄── the IContentProvider binder we want
out.writeStrongBinder(connection)
out.writeInt(noReleaseNeeded ? 1 : 0)
out.writeInt(mLocal ? 1 : 0)
```

So to reach `provider`, the daemon must **skip exactly the `info` (ProviderInfo)
parcel**, whose length is not prefixed (plain `writeToParcel`, not
`writeTypedObject`). §4 is the dilemma this creates.

### 3.5 Wire level 2 — `IContentProvider.call` and the `sendBinder` bundle

`CALL_TRANSACTION` = `IBinder.FIRST_CALL_TRANSACTION (1) + 20` = **21** (stable
constant, absent from DEX; from AOSP `IContentProvider.java`). Interface method:
`Bundle call(AttributionSource callingAttributionSource, String authority,
String method, String arg, Bundle extras)`.

Request (Android 14, from AOSP `ContentProviderNative.java:296-313`):

```
writeInterfaceToken("android.content.IContentProvider")
AttributionSource.writeToParcel        // S+; see below
writeString(authority)
writeString(method)                    // "sendBinder"
writeString(arg)                       // authority again
writeBundle(extras)                    // holds our service binder
```

`AttributionSource.writeToParcel` (Android 14) delegates to
`AttributionSourceState.writeToParcel` — the AIDL-generated layout
(`AttributionSourceState`, `android.content`, SDK 34 mirror):

```
writeInt(_aidl_start_size)   // AIDL: (end_pos − start_pos), INCLUDING the size
                             // int itself. All-null optionals + pid/uid:
                             // size 4 + pid 4 + uid 4 + pkg-null 4 + tag-null 4
                             // + token-null 24 + renounced-null 4 + next-null 4
                             // = 52. Reader: setDataPosition(start + 52).
writeInt(pid)                // e.g. Process.INVALID_PID = -1
writeInt(uid)                // 2000 (the daemon's own uid — NOT read by server)
writeString(packageName)     // null
writeString(attributionTag)  // null
writeStrongBinder(token)     // null → flat_binder_object (24 bytes)
writeStringArray(renouncedPermissions)      // null → writeInt(-1)
writeTypedArray(next, flags) // null → writeInt(-1)
```

The `extras` `Bundle` must be built so the app can read the binder back. AOSP
`BaseBundle.writeToParcel`: empty bundle = `writeInt(0)`; otherwise
`writeInt(byteLength)` + `writeInt(BUNDLE_MAGIC=0x4C444E42)` + `writeInt(N)` +
per key `writeString(key)` + `writeValue(value)`, where the binder value type is
`VAL_IBINDER = 15` → `writeInt(15)` + `writeStrongBinder(binder)`. For the
single-entry `{"binder": IBinder}` bundle the `writeToParcelInner` length
field is exact: `N=1` 4 + String16 `"binder"` 20 + `VAL_IBINDER` 4 +
flat_binder_object 24 = **52** (the total bundle on the wire is 60 with the
length int and magic). Shizuku wraps
the binder in a `BinderContainer` parcelable instead; either is valid as long as
the app's `call()` reads it back (`.getBinder(key)` / the container).

Reply (app → daemon): `writeNoException()` + `writeBundle(responseBundle)` —
readable with the crate's existing `ParcelReader` primitives
(`read_int32`, `read_string`, `read_strong_binder`).

### 3.6 Wire level 3 — the ProviderInfo chain (the cost of the handoff)

`ContentProviderHolder.info` is a `ProviderInfo` whose parcel is a variable-length
chain (`ProviderInfo.writeToParcel` → `super` = `ComponentInfo` → `super` =
`PackageItemInfo`, plus `ApplicationInfo` inside `ComponentInfo`). Extracted
field orders (Android 14):

- **ProviderInfo** (`ProviderInfo.java:146-159`): `super.writeToParcel`,
  `writeString8(authority)`, `writeString8(readPermission)`,
  `writeString8(writePermission)`, `writeInt(grantUriPermissions)`,
  `writeInt(forceUriPermissions)`, `writeTypedArray(uriPermissionPatterns)`
  (PatternMatcher[]), `writeTypedArray(pathPermissions)` (PathPermission[]),
  `writeInt(multiprocess)`, `writeInt(initOrder)`, `writeInt(flags)`,
  `writeInt(isSyncable)`.
- **ComponentInfo** (`ComponentInfo.java:233-243`): `super.writeToParcel`,
  `applicationInfo.writeToParcel` (the big one — full field walk below),
  `writeString8(processName)`, `writeString8(splitName)`,
  `writeString8Array(attributionTags)`, `writeInt(descriptionRes)`,
  `writeInt(enabled)`, `writeInt(exported)`, `writeInt(directBootAware)`.
- **PackageItemInfo** (`PackageItemInfo.java:435-445`): `writeString8(name)`,
  `writeString8(packageName)`, `writeInt(labelRes)`,
  `TextUtils.writeToParcel(nonLocalizedLabel)` (a CharSequence — **variable**),
  `writeInt(icon)`, `writeInt(logo)`, `writeBundle(metaData)` (**variable**),
  `writeInt(banner)`, `writeInt(showUserIcon)`.

#### 3.6.1 The full `ApplicationInfo.writeToParcel` walk (the version-fragile core)

Verified field-by-field from AOSP android-14.0.0_r1
(`ApplicationInfo.java:1987-2077`, `Parcel.java`, `Parcelling.java`). This is
what the daemon must skip — and where it reads `uid` (2B in §5) — to reach the
provider binder. Every line is a wire primitive:

1. `dest.maybeWriteSquashed(this)` — **squash marker**: `writeInt(0)` (first
   write; the reply parcel is never squash-rewound, so the marker is always 0,
   but a non-zero marker would mean "skip to earlier position" and must be
   rejected by the walk).
2. `super.writeToParcel` — the PackageItemInfo block (§3.6): `name`,
   `packageName`, `labelRes`, `nonLocalizedLabel` CharSequence, `icon`, `logo`,
   `metaData` Bundle, `banner`, `showUserIcon`.
3. `writeString8(taskAffinity)`, `writeString8(permission)`,
   `writeString8(processName)`, `writeString8(className)` — 4 strings.
4. `writeInt(theme)`, `writeInt(flags)`, `writeInt(privateFlags)`,
   `writeInt(privateFlagsExt)`, `writeInt(requiresSmallestWidthDp)`,
   `writeInt(compatibleWidthLimitDp)`, `writeInt(largestWidthLimitDp)` — 7 ints.
5. `storageUuid`: `writeInt(0)` (null) **or** `writeInt(1)` + `writeLong` +
   `writeLong` (2 longs).
6. `writeString8(scanSourceDir)`, `writeString8(scanPublicSourceDir)`,
   `writeString8(sourceDir)`, `writeString8(publicSourceDir)` — 4 strings.
7. `writeString8Array(splitNames)`, `writeString8Array(splitSourceDirs)`,
   `writeString8Array(splitPublicSourceDirs)` — 3 arrays; null → `writeInt(-1)`,
   else `writeInt(N)` + N×String8.
8. `writeSparseArray(splitDependencies)` — SparseArray<int[]>: null →
   `writeInt(-1)`; else `writeInt(N)` + per entry `writeInt(key)` +
   `writeValue(int[])` = `writeInt(18)` (VAL_INTARRAY, not length-prefixed) +
   `writeIntArray` (`writeInt(len)` + len×`writeInt`).
9. `writeString8(nativeLibraryDir)`, `writeString8(secondaryNativeLibraryDir)`,
   `writeString8(nativeLibraryRootDir)`,
   `writeInt(nativeLibraryRootRequiresIsa ? 1 : 0)`,
   `writeString8(primaryCpuAbi)`, `writeString8(secondaryCpuAbi)`.
10. `writeString8Array(resourceDirs)`, `writeString8Array(overlayPaths)`,
    `writeString8(seInfo)`, `writeString8(seInfoUser)`,
    `writeString8Array(sharedLibraryFiles)`.
11. `writeTypedList(sharedLibraryInfos)` — List<SharedLibraryInfo>: null →
    `writeInt(-1)`; else `writeInt(N)` + per entry `writeTypedObject`
    (`writeInt(1)` + `SharedLibraryInfo.writeToParcel` body, or `writeInt(0)`
    for a null element).
12. `writeString8(dataDir)`, `writeString8(deviceProtectedDataDir)`,
    `writeString8(credentialProtectedDataDir)`.
13. **`writeInt(uid)`** — the pinned-uid source for §5 (2B). Everything from
    step 14 on is still walked (the provider binder comes after), so capturing
    `uid` costs nothing extra.
14. `writeInt(minSdkVersion)`, `writeInt(targetSdkVersion)`,
    `writeLong(longVersionCode)`, `writeInt(enabled ? 1 : 0)`,
    `writeInt(enabledSetting)`, `writeInt(installLocation)`,
    `writeString8(manageSpaceActivityName)`, `writeString8(backupAgentName)`,
    `writeInt(descriptionRes)`, `writeInt(uiOptions)`,
    `writeInt(fullBackupContent)`, `writeInt(dataExtractionRulesRes)`.
15. `writeBoolean(crossProfile)` — `writeInt(1|0)`, `writeInt(networkSecurityConfigRes)`,
    `writeInt(category)`, `writeInt(targetSandboxVersion)`,
    `writeString8(classLoaderName)`, `writeString8Array(splitClassLoaderNames)`,
    `writeInt(compileSdkVersion)`, `writeString8(compileSdkVersionCodename)`,
    `writeString8(appComponentFactory)`, `writeInt(iconRes)`,
    `writeInt(roundIconRes)`, `writeInt(mHiddenApiPolicy)`,
    `writeInt(hiddenUntilInstalled ? 1 : 0)`, `writeString8(zygotePreloadName)`,
    `writeInt(gwpAsanMode)`, `writeInt(memtagMode)`,
    `writeInt(nativeHeapZeroInitialized)`.
16. **`sForBoolean.parcel(requestRawExternalStorageAccess, …)`** — a Parcelling
    builtin, **not** a plain boolean: null → `writeInt(1)`, false → `writeInt(0)`,
    true → `writeInt(-1)`. The reversed null/true encodings are a trap for any
    walk that treats it as `writeInt(0|1)`.
17. `writeLong(createTimestamp)`.
18. `mAppClassNamesByProcess` (SparseArray<String>, written manually): null →
    `writeInt(0)` (**not** -1); else `writeInt(N)` + per entry
    `writeString(key)` + `writeString(value)` — note **String16** (`writeString`),
    unlike the String8s everywhere else.
19. `writeInt(localeConfigRes)`.
20. **`sForStringSet.parcel(mKnownActivityEmbeddingCerts, …)`** — a Parcelling
    builtin: null → `writeInt(-1)`; else `writeInt(N)` + N×`writeString`
    (String16).

The two Parcelling builtins (steps 16, 20) are internal framework machinery with
no API contract; the `ApplicationInfo` layout as a whole changes across releases
(`privateFlagsExt`, `mAppClassNamesByProcess`, `sForBoolean` all landed in
specific SDKs). This is why §4 calls the walk the version-fragile spot and why it
must be validated against the live jar per device.

The variable-length fields (CharSequence, Bundle, typed arrays, nested
ApplicationInfo) make the provider binder land at a **non-constant offset** — the
daemon must replicate this walk to skip it. §4 Dilemma 1 is about this.

### 3.7 What the app must implement (the other half of the contract)

The app ships an **exported** `ContentProvider` (authority `<pkg>.shizuku`,
`android:exported="true"`) whose `call()` handles `"sendBinder"`:
read the service binder out of the extras bundle, hold it, and expose a
`Binder.transact` wrapper for exec requests. This is the same contract Shizuku's
client SDK provides. The daemon only needs the *provider side* to exist; the
whitelist decides *which* apps are handed the binder.

### 3.8 Reference implementation: AxManager (a working, shipped analogue)

AxManager (github.com/fahrez182/AxManager) is the closest production analogue to
this design: a shell-uid privileged daemon that hands a service binder to
apps. It is a fork of Shizuku's server — it reuses `moe.shizuku.server.*`,
`rikka.hidden.compat.*`, and the Shizuku-API client wholesale — so it is direct
evidence that the mechanism in §3.5/§3.6 is the real, shipped pattern, not a
paper invention. What it does (verified against source):

- **Daemon bootstrap**: `starter.cpp` (a native `app_process` argv builder)
  rejects any uid other than 0 or 2000, then fork→setsid→execvp
  `/system/bin/app_process` with `-Djava.class.path=<apk>` and the main class
  `AxeronService`. That main is just
  `Looper.prepareMainLooper(); new AxeronService(); Looper.loop()` — a `Service`
  instantiated directly, no ActivityThread machinery. **It runs on the full ART
  framework**, which is the entire point: the framework's hidden APIs
  (`getContentProviderExternal`, observers, `addPowerSaveTempWhitelistApp`) *are*
  the mechanism.
- **Handoff (push model)**: `BinderSender.register()` subscribes a
  `ProcessObserver` (foreground changes) + `UidObserver` (GONE/IDLE/ACTIVE/
  CACHED). On a uid going active / process coming foreground, it calls
  `sendBinder(uid, pid)`, which: (1) dedups on `uid:pid` via a `SENT_BINDERS`
  set — the same idea as our §6 dedup gate; (2) resolves the caller's packages
  via `getPackagesForUid`; (3) checks `PackageInfo.requestedPermissions` for a
  manager or API_V23 permission and confirms it is *granted* via
  `checkPermission`; (4) `getContentProviderExternal("<pkg>.shizuku", …)` →
  `pingBinder()` → `call(provider, …,"sendBinder", extra)` where `extra` is a
  Bundle carrying `BinderContainer(serviceBinder)` →
  `removeContentProviderExternalAsUser(name, token, userId)`
  in a `finally`. This is exactly our §3.5/§3.6 wire shape, proven in the wild.
  If `pingBinder()` fails it `forceStopPackage`s the target and retries once.
- **Temp doze whitelist**: before handoff it calls
  `DeviceIdleControllerApis.addPowerSaveTempWhitelistApp(pkg, 30s, …,
  REASON_SHELL)` so the target app is not dozed/backgrounded mid-handoff. This
  directly addresses the §6 "target killed or dozed before it can use the
  binder" race and is a shell-uid-callable API.
- **Per-transaction authz, not pinned uid**: `ShizukuServiceIntercept`
  implements `IShizukuService.Stub` and delegates `checkPermission`,
  `getFlagsForUid/updateFlagsForUid` to Shizuku's `ConfigManager` (a persisted
  per-uid allow/deny JSON). Every call goes through this. `exit()` reads
  `getCallingUid()` on the transaction — confirming per-transaction caller
  identity is available and used.

**Why it validates this design**: the handoff contract, the dedup, the
provider-per-app naming (`<pkg>.shizuku`), the Bundle-carried binder, the
`pingBinder` dead-provider check, and the use of per-transaction calling uid are
all real, shipped mechanisms. Our §3.6.1 walk exists precisely because AxManager
*doesn't* hand-walk — the framework does it for them.

**Where this design is deliberately better/efficient (and where it isn't)**:

| Dimension | AxManager | This design | Verdict |
|---|---|---|---|
| Daemon footprint | full ART + framework process | native Rust daemon | **better** (ms startup, tiny RSS) |
| Hot path per-call gate | ConfigManager flags/JSON, delegated per call | one stateless `AIBinder_getCallingUid()` read vs a pinned uid, zero IPC | **better** (faster, no state) |
| Handoff blast radius | pushes the binder to *every* installed API_V23 app on every uid/foreground transition | exactly one whitelisted fg app + dedup gate | **better** (less binder surface) |
| Dead-provider handling | `forceStopPackage` (kills the target app) + retry | no kill path; §6 dedup + pinned-uid re-handoff | **better** |
| Walk correctness | inherited from the framework | hand-rolled §3.6.1 walk, ROM-fragile | **worse** — this is the whole Dilemma 1 |
| Authz capability | full per-uid flags + request-permission UI | deliberately minimal 2B gate + same-uid residual | **worse** — narrower on purpose |

The honest summary: this design is more efficient on the hot path and strictly
lighter, but that efficiency is bought with the hand-rolled walk — the exact
fragility Dilemma 1 warns about. AxManager's framework-backed correctness is the
baseline we are trading against, and the walk (§3.6.1) + on-device validation
(§7) is the price.

## 4. Dilemma 1 — Skipping `ProviderInfo` in the holder reply (the engineering crux)

The provider binder sits at `holder.data[after info]`, and `info` has **no length
prefix**. Options:

| Option | Shape | Cost | Risk | Verdict |
|---|---|---|---|---|
| **1A. Full hand-walk** | replicate `ProviderInfo`→`ComponentInfo`→`PackageItemInfo`(+`ApplicationInfo`) field-by-field with the crate's `ParcelReader` (`read_int32`, `read_string8`, skip bundle/typed-array/CharSequence) | one focused module; every field listed in §3.6 is a known primitive or a known skip | **version fragility**: any ROM that reorders/extends these classes silently shifts the binder offset; needs a live-jar cross-check per device | feasible; must be validated against this ROM's jar (§7) |
| **1B. `readStrongBinder` scan** | read the whole reply, treat it as bytes, and `readStrongBinder` at the first plausible boundary | no schema | fragile/hacky; binder handles have no magic number to find by | rejected |
| **1C. Use `getContentProvider` instead of `getContentProviderExternal`** | tx 30 (also resolved) returns the same holder shape | same walk needed | no shorter path; different API surface for shell | rejected (same parse cost) |
| **1D. Ask the app to expose the binder another way** | e.g. the app drops its binder in a file / the daemon calls into a binder the app *already* owns | changes the contract; a sandboxed app cannot host a reachable binder for a shell process except through a provider | rejects the whole premise | rejected |

**Working decision:** 1A, with the walk validated against the live jar before
relying on it. The walk is fully specified in §3.6.1 (every field is a known
primitive or a known skip, including the two Parcelling builtins) and reuses
existing `ParcelReader` primitives; the risk is bounded by an on-device smoke
test (§7) and by keeping the walk in one module so a ROM bump is a localized
fix. The estimate from §3.6.1 is ~120-150 lines plus per-device validation —
dominated by the `ApplicationInfo` tail (steps 14-20), which is the most
release-volatile part.

## 5. Dilemma 2 — What the server binder is authorized to do (the per-call gate)

Shizuku checks the caller on *every* transaction
(`ShizukuService.checkCallerPermission`), and it does so precisely because a
Binder proxy is a **transferable bearer credential, not a bound one**: an
`IBinder` reference is not pinned to the process it was handed to — any process
holding one can forward it through ordinary IPC (an Intent extra, a Bundle sent
to another component, an exported activity/receiver), and then *that* process
transacts the daemon's exec service. The brief's "no uid machinery" was aimed at
the **heavyweight** pieces — `registerProcessObserver`/`registerUidObserver`
subscriptions and `getPackagesForUid` scans, the always-on state Dilemma 3
rightly rejected for driving the *handoff lifecycle*. A stateless
`AIBinder_getCallingUid()` read inside `on_transact` is a different cost class:
a synchronous read of the kernel-supplied caller identity on the transaction
already in flight — conceptually identical to `SO_PEERCRED` on the sibling
`@coreshift_exec` socket, which the design already relies on as unspoofable. The
two are not the same thing, and the constraint is scoped to the former.

The consequence of dropping all per-call verification (2A): a leaked or
re-forwarded proxy grants root-equivalent exec permanently, with zero daemon
visibility — no gate and no log of who is actually calling. That is strictly
weaker than the sibling socket channel, and it is the one decision in this
document that is unsafe to accept by default.

| Option | Mechanism | Protects against | Weakness | Verdict |
|---|---|---|---|---|
| **2A. No gate** | any process holding the proxy can transact exec | nothing at transact time | leaked/re-forwarded proxy = permanent root-exec, invisible to the daemon | **rejected** |
| **2B. Pinned-uid per-transaction check** | at handoff, resolve and pin the whitelisted package's uid **once** (a single lookup — from the `ApplicationInfo.uid` field already walked in §3.6, so no extra IPC and no `getPackagesForUid` scan); store it with that handoff generation; in `on_transact`, reject unless `AIBinder_getCallingUid() == pinned_uid` | a leaked proxy handed to a third process fails immediately — the caller identity reflects who is *actually* transacting now | needs the uid pinned per handoff generation; a stale pin after app restart must be refreshed by the same re-handoff path | **chosen** |
| **2C. Per-transaction shared-secret check** | app must include a token issued at handoff | leakage without the token | more wire machinery; contradicts the "just send the binder" simplicity | rejected |

**Decision:** 2B. The check is a few lines inside the existing dispatch path —
one `AIBinder_getCallingUid()` (the NDK symbol is already dlsym-able through the
same `Vtable` that resolves the rest of libbinder_ndk) compared against the
handoff-generation's pinned uid. It adds no threads, no subscriptions, no
persistent watching — it is not the machinery Dilemma 3 correctly ruled out.
Pinning the uid at handoff time (rather than per call) keeps the "no
`getPackagesForUid` scanning" constraint intact: one lookup per handoff, not per
transaction.

## 6. Dilemma 3 — Sender lifecycle: restarts, multiple users, ordering

The sender is driven by fg-resolution (mailbox), not by a process/uid observer.
That has consequences:

- **App restart.** Shizuku re-sends the binder on `registerUidObserver`
  `UID_OBSERVER_*` events. Without an observer, a *whitelisted* app that is
  killed and restarted while still foreground gets a stale/absent provider
  handoff until the next fg *change* event. Options: (a) accept the gap and
  re-handoff on every fg notification even for an unchanged package; (b) accept a
  *timer* re-send while the fg package is whitelisted; (c) reintroduce a minimal
  `registerProcessObserver`-style wakeup — which the user constraint excludes.
- **Provider already open.** Calling `getContentProviderExternal` twice on the
   same authority may return the same holder; the app's `call()` should be
   idempotent (storing the same binder twice is harmless).
   `removeContentProviderExternalAsUser` must be paired with each successful
   acquire (unless the holder set `noReleaseNeeded`).
- **Non-whitelisted fg.** The sender must be a no-op (do not resolve providers
  for packages not on the whitelist) — the whitelist check happens *before*
  any Binder work.
- **Ordering vs. fg resolution.** If the fg resolution reports the package after
  a slight delay, a just-started app may be missed; the same re-send policy as
  (a) covers it.
- **Redundant notifications.** The ProviderInfo hand-walk (§3.6) is a real cost —
  dozens of fields including a full `ApplicationInfo` — so "re-send on every fg
  notification" is only acceptable if the mailbox delivers *genuine* package
  transitions, not a wake on every reactor event. This project has a documented
  history of "we assumed the event only fires on a genuine change, strace proved
  it fires on every wake" (the fps registration-churn investigation). Before
  trusting idempotency to absorb the cost, the mailbox must be verified to emit
  only genuine transitions; independently of that, an **explicit dedup gate** is
  cheap insurance.

**Working decision:** (a) re-run the handoff for the whitelisted fg package on
fg notification — **but gated**: skip the re-handoff when the incoming package
matches the last *successfully handed-off* package and its pinned uid is still
current, mirroring the `want != registered` pattern already used in the fps
demand-sync logic. This dedup is a plain equality check on the sender state, not
a new subscription, and it is what makes the §3.6 walk cost bounded regardless of
mailbox semantics. Document the restart gap as accepted for this cut; a
timer-based re-send is the fallback if the gap proves real in testing.

## 7. Dilemma 4 — Validation without a device

`cargo check --target aarch64-linux-android` compiles the android-gated code
(there is no NDK link step needed for check), but no binder transaction can be
executed here. What can be verified in this environment, and what cannot:

| Can verify here | How |
|---|---|
| Tx-code resolution (129/30/121/165/20 + DEX-resolved remove-AsUser) | `src/android/dex.rs` `find_transaction_code` against `/mnt/shared/exp/framework.jar` (already done) |
| The full §3.6.1 walk (ProviderInfo + ApplicationInfo incl. both Parcelling builtins) against the **spec** | unit tests constructing `Parcel` buffers byte-by-byte per §3.6.1 and asserting the walk lands on `uid` and then on the provider binder offset |
| Parcel-build byte layout of the `call` request, the `sendBinder` bundle, and the holder-walk skips | unit tests constructing `Parcel` buffers and asserting offsets against the §3.5/§3.6 orders |
| Compilation of the whole android-gated module | `cargo check --target aarch64-linux-android` |
| Stub correctness on non-Android | `cargo test` on host (stubs return `Unsupported`) |

| Needs a device | Why |
|---|---|
| Actual `getContentProviderExternal` round-trip (permissions, SELinux for the shell uid resolving an app's provider) | system_server policy, not reproducible locally |
| The ProviderInfo/ApplicationInfo walk against the *real* device holder — **including `uid`** | ROM differences beyond the one live jar; the §3.6.1 layout (esp. steps 16-20) is release-volatile |
| End-to-end app handoff + transact | needs the app side installed |

**Decision:** build a host-runnable `tests/` module that byte-checks every parcel
the daemon writes against the documented layouts, plus a device smoke-test
procedure as a documented release requirement. The `tests/tx_resolve_tmp.rs`
harness is the seed for the tx-code half of that.

## 8. Decision record

1. **Transport (§3):** Binder; the daemon pushes its exec `BinderService` proxy
   into the app through the app's exported ContentProvider
   (`getContentProviderExternal` → `IContentProvider.call("sendBinder")` →
   `removeContentProviderExternalAsUser`).
2. **Sender driver (§3.2):** the existing fg-resolution mailbox, not UID/process
   observers. Re-send on fg notification for a whitelisted package, **gated by a
   dedup equality check** against the last successfully handed-off package+pinned
   uid (the fps `want != registered` pattern), so the §3.6 walk cost is bounded
   even if the mailbox fires redundantly.
3. **Authorization (§5):** 2B — per-transaction pinned-uid gate. At handoff,
   resolve the whitelisted package's uid once (from `ApplicationInfo.uid` in the
   §3.6 walk; no extra IPC, no `getPackagesForUid`), pin it to that handoff
   generation, and reject any `on_transact` caller whose
   `AIBinder_getCallingUid()` does not match. No observers, no subscriptions, no
   shared secrets. This closes the binder-proxy-leak vector; the env-var
   whitelist remains the only *acquisition* gate, applied before any Binder
   work.
4. **ProviderInfo skip (§4):** 1A hand-walk in one module, fully specified in
   §3.6.1 (every field incl. both Parcelling builtins), validated against the
   live jar (§7); documented as the fragile, version-sensitive spot. The
   shipped AxManager analogue (§3.8) validates the surrounding mechanism but
   deliberately does *not* hand-walk — the framework does it for them — so this
   walk is the unique fragility this design trades for its footprint gains.
5. **Wire target (§3.4-3.6):** Android 14 / SDK 34 exclusively; tx codes
    resolved from the live framework.jar (getContentProviderExternal=129,
    removeContentProviderExternalAsUser=DEX-resolved, getContentProvider=30,
    registerProcessObserver=121, getFocusedRootTaskInfo=165,
    getPackagesForUid=20, CALL_TRANSACTION=21 stable).
6. **App contract (§3.7):** exported provider authority `<pkg>.shizuku`
   implementing `call("sendBinder")`; idempotent store of the received binder.
7. **Validation (§7):** host-runnable parcel byte-tests + `cargo check --target
   aarch64-linux-android` + documented device smoke test.

## 9. How to respond

This document's purpose is to give the complete picture so a better solution can
be found if one exists. In particular, challenge:
- §4 (is there a *cheaper* way to reach the provider binder than replicating the
  whole ProviderInfo chain?);
- §5 (is the pinned-uid gate enough? the residual risk is a leaked proxy being
  transacted from *the same uid* — e.g. an exported component of the pinned app
  forwarding it to another process *of that same app*, or a malicious process
  holding the app's uid — which 2B does not cover; the socket channel's
  `SO_PEERCRED` has the same blind spot, so this is at parity, but it is worth
  stating);
- §6 (is the no-observer restart gap acceptable, or does it need a timer? and:
  the mailbox-fires-on-every-wake assumption is to be strace-verified, not
  assumed).

Any change to transport, authorization, or the sender lifecycle updates this
record with the design docs together.

---

*Constraint references: `src/binder/mod.rs` (Vtable at 260-292, `transact_write`,
thread pool, stubs), `src/android/dex.rs` (`find_transaction_code`),
`tests/tx_resolve_tmp.rs` (live-jar resolution); FocusSource
`src/daemon/{mod,hub,fg,exec}.rs`, `EXEC-PRIVILEGED-EXECUTION-DILEMMA.md`;
Shizuku `starter.cpp`, `BinderSender.java:185`, `ShizukuService.java:331-392`,
`IContentProviderUtils.java`; AxManager (github.com/fahrez182/AxManager)
`server/src/main/cpp/starter.cpp`, `BinderSender.java`,
`AxeronService.kt` (`sendBinderToUserApp`), `ServiceStarter.java`,
`ShizukuServiceIntercept.kt`, `AxeronConfigManager.java`, `Shell.java`,
`manager/.../AxManagerProvider.kt`; AOSP android-14.0.0_r1
`ContentProviderNative.java:296-313`,
`IContentProvider.java:152-153`, `ContentProviderHolder.java:58-68`,
`ProviderInfo.java:146-159`, `ComponentInfo.java:233-243`,
`PackageItemInfo.java:435-445`, `ApplicationInfo.java:1987-2077`,
`AttributionSourceState` (SDK 34 AIDL), `BaseBundle`,
`Parcel.java` (incl. `writeSparseArray` 1445, `maybeWriteSquashed` 2698,
`writeTypedList` 2051, `writeValue` 2348, `isLengthPrefixed` 4762),
`Parcelling.java` (ForBoolean 252, ForStringSet 171); live ROM
`/mnt/shared/exp/framework.jar`, `build.prop` (SDK 34).*