Mercury action interceptor (GAMBIT) — design decisions distilled¶
Status: verified — 2026-04-28, two rounds of static disasm research
Compaction-survivable distillation of Phase D.6.6-impl sub-task 3
design research. The unredacted host-side notes are at
notes/iosmux-mercury-action-design-research.md (round 1) and
notes/iosmux-mercury-action-design-research.md →
notes/iosmux-gambit-design-decisions.md (round 2). Both are
gitignored per project policy on raw research notes; this doc is
the public, repo-surviving distillation.
This doc is the design baseline for sub-task 3 implementation. Every
claim is sourced (file path + offset + xcrun swift-demangle output
where applicable) per
ADR-0006.
Hook target¶
Single hook: prologue of
CoreDeviceUtilities.invoke(anyOf:usingContentsOf:) in
/Library/Developer/PrivateFrameworks/CoreDeviceUtilities.framework/Versions/A/CoreDeviceUtilities.
- Mangled symbol:
_$s19CoreDeviceUtilities6invoke5anyOf013usingContentsF0SbSayAA20ActionImplementation_pXpG_7Mercury13XPCDictionaryVtF - Demangled:
CoreDeviceUtilities.invoke(anyOf: [CoreDeviceUtilities.ActionImplementation.Type], usingContentsOf: Mercury.XPCDictionary) -> Swift.Bool - File offset:
0xff50on x86_64,0xfeb0on arm64e (different per arch — usedlsym(RTLD_DEFAULT, "<mangled>")for resolution, NOT a hardcoded offset). - Resolution mechanism: inline 13-byte prologue patch mirroring the
proven S1.B trampoline pattern at
inject/iosmux_inject.m:1463–1525. The prologue at offset 0xff50 has 14 bytes of clean linear instructions (push rbp / mov rsp,rbp / push r15..r12 / push rbx / sub rsp,$0xe8) before any pc-relative instruction; ample room for a 13-bytemovabs r10, hook_fn+jmp r10. DYLD_INTERPOSE does NOT work for this symbol — Swift internal calls go through directcallqrel32, not__gotindirect.
This single hook catches every action-invocation dispatch — one entry
point for all action IDs on the bus. Returning true from the hook
short-circuits the original dispatch chain (the function's own return
type means "I handled this"); returning false falls through to
Apple's original invoke(anyOf:) via the trampoline.
Calling convention¶
The function is a module-level free function (no self register, no
class context):
| Reg | Role |
|---|---|
%rdi |
param 1: [CoreDeviceUtilities.ActionImplementation.Type] (Swift array) |
%rsi |
param 2: Mercury.XPCDictionary (struct value; field 0 = underlying xpc_object_t) |
%al |
return: Bool (single byte) |
Swift cc matches System V AMD64 cc for this 2-argument layout. Plain C
function signature bool fn(void *array, void *mercury_dict_struct)
produces the matching register layout — no swiftcall attribute
needed on x86_64. arm64e parallel holds (x0, x1) but
pointer-auth (pacibsp) requires a different inline-patch encoding —
out of scope for the x86_64-only MVP.
Reply construction (Apple-aligned)¶
The synthetic reply mirrors Apple's own construction at
CoreDeviceUtilities reply sites (0xf75e0, 0xf7d22, 0x14e5a4):
xpc_object_t reply = xpc_dictionary_create_reply(request);
/* libxpc auto-threads the reply context to the originator. */
/* Echo envelope keys verbatim from request: */
xpc_dictionary_set_string(reply, "CoreDevice.actionIdentifier", aid);
xpc_dictionary_set_uuid( reply, "CoreDevice.invocationIdentifier", iid);
xpc_dictionary_set_uuid( reply, "CoreDevice.deviceIdentifier", did);
xpc_dictionary_set_string(reply, "CoreDevice.coreDeviceVersion", cdv);
/* Empty Output (Codable Void encoding — see "Output bytes" below): */
xpc_object_t empty_out = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_value(reply, "CoreDevice.output", empty_out);
xpc_release(empty_out);
/* NO `CoreDevice.error` key on success — empirically that key
does NOT exist in CoreDevice / CoreDeviceUtilities binary. The
discriminator success/fail is presumably presence/shape of
`CoreDevice.output`, not an error key. */
/* Send: mirror Mercury's `XPCDictionary.sendReply()` pattern at
`Mercury+0x2db30`. */
xpc_connection_t conn = xpc_dictionary_get_remote_connection(request);
if (xpc_get_type(conn) == XPC_TYPE_REMOTE_CONNECTION) {
xpc_remote_connection_send_message(conn, reply);
} else {
xpc_connection_send_message(conn, reply);
}
xpc_release(reply);
Output bytes — try ordering¶
Apple's exact Codable encoding of Void for CoreDevice.output is
not statically observable. Three plausibilities to try at deploy-time
(env-gated for rebuild-free iteration —
IOSMUX_GAMBIT_OUTPUT_MODE=empty|absent|null):
- Empty
xpc_dictionary(priority 1) —xpc_dictionary_create(NULL, NULL, 0) - Absent
CoreDevice.outputkey (priority 2) — set nothing, only echo envelope keys xpc_null(priority 3) —xpc_null_create()
If all three fail, escalate to Phase 4 (friend's working CDS+device pair captures a real successful reply byte-exact). Phase 4 is last-resort.
Allow-list — 15 actions, ADR-0009 §Consequences exact¶
GAMBIT intercepts ONLY the static-success set per
ADR-0009
§Consequences. All 15 have empirically Output == Void per Swift
symbol-table evidence (no *ActionDeclaration.Output nominal type
descriptor exists for any of them in CoreDevice / CoreDeviceUtilities).
com.apple.coredevice.action.pair
com.apple.coredevice.action.unpair
com.apple.coredevice.action.acquireusageassertion
com.apple.coredevice.action.listusageassertions
com.apple.coredevice.action.connect
com.apple.coredevice.action.disconnect
com.apple.coredevice.action.tags
com.apple.coredevice.action.gettrainname
com.apple.coredevice.action.darwinnotificationobserve
com.apple.coredevice.action.darwinnotificationpost
com.apple.coredevice.action.enableddiservices
com.apple.coredevice.action.disableddiservicesaction
com.apple.coredevice.action.fetchddimetadata
com.apple.coredevice.action.updatehostddis
com.apple.coredevice.action.removehostddis
com.apple.coredevice.action.lockstate is NOT in this list.
LockStateActionDeclaration returns CoreDevice.LockState (non-Void —
struct with lock state and hasBeenUnlockedSinceBoot field).
Static-success would lie about real device lock state; not a
candidate for synthesis. The 60-action superset documented in round-2
research notes is informational only — only the 15 above are
GAMBIT's targets.
For any other action ID, GAMBIT returns false from the hook,
falling through to Apple's original invoke(anyOf:) via the
trampoline. The forward-through-tunnel actions (appinstall,
createservicesocket, transferfiles, receivefiles,
rsyncfiles, listfiles, fetchdyldsharedcachefiles,
fetchmachodylibs, filenodedetails, debug-attach via
service-socket open) per ADR-0009 §Consequences flow through the
existing remote_service_create_connected_socket interpose chain in
inject/iosmux_inject.m — NOT through GAMBIT.
Failure mode — passthrough¶
On any internal failure (NULL xpc_dictionary_create_reply, NULL
remote connection, etc.) GAMBIT goes to passthrough label, which
tail-calls Apple's original via the trampoline:
passthrough:
return ((bool (*)(void*, void*))g_invoke_anyof_trampoline)(
candidates_array, mercury_dict_struct);
Apple's own invoke(anyOf:) returns true on its
"unsupported action" branch and FATAL'ies on xpc_dictionary_create_reply==NULL
(at 0x14e5a4 and 0xf76ed). GAMBIT inherits Apple's natural return
value via tail-call rather than hardcoding true/false.
Endpoint side-channel — ignored in v1¶
CoreDevice.input.endpoint is a xpc_endpoint_t mach send right
that CDS opens with xpc_connection_create_from_endpoint (3 call
sites in CoreDeviceService binary). It carries streaming progress
events (PairActionUpdate.challenge(Bool, Double?),
AcquireDeviceUsageAssertionActionDeclaration.ProgressUnit.{started,
createdTunnelConnection, enablingDeveloperDiskImageServices,
fetchingExtendedDeviceInfo, completed}) — NOT the primary reply
path.
GAMBIT v1 ignores the endpoint entirely. If Xcode rejects a
static-success reply for missing progress events, add endpoint-side
.started/.completed emission in a follow-up iteration.
Logger × GAMBIT coexistence¶
The existing Mercury logger
(inject/iosmux_mercury_logger.m) interposes 4 libxpc functions:
xpc_connection_send_message{,_with_reply,_with_reply_sync} (send
side) and xpc_connection_set_event_handler (recv side).
GAMBIT's reply-send call goes through the existing send-side
interpose — the logger logs dir="send" for the synthetic reply,
then forwards to the real libxpc symbol. Free audit trail. No
re-entrancy; set_event_handler is recv-side, send paths don't cycle
back through it.
Threading: GAMBIT's hook function runs on whichever Swift
cooperative-queue thread Mercury dispatched the action onto. No
shared GAMBIT state required for the synth-reply path; the logger's
g_jsonl_mu mutex serialises JSONL writes — GAMBIT does not touch
that.
Three deploy-time empirical bits — RESOLVED 2026-04-28 (iter 1-17)¶
Exact Codable encoding of Void for— FALSIFIED: Output is NOT Void. Apple decoder named the actual type on iter 3 (CoreDevice.outputnullmode). PairAction's Output isCoreDevice.DeviceConnectionChangeResult(struct with two required fields:outcome: Outcome,updatedSnapshot: DeviceStateSnapshot). Full Codable schema for the entire reply, including all 13 required DeviceInfo fields, all type-form quirks (xpc_uuid vs xpc_string, Int64 vs UInt64, String-rawValue vs auto-Codable enum), and the DeviceIdentifier custom-named-keys structure, is ingambit-pair-action-schema.md.Whether progress events are required on the input endpoint— PARTIALLY ANSWERED: not required for Apple's decoder to accept the reply (iter 17 confirmedPairing attempt completed with error nil). But Xcode UI does NOT transition out of the spinner state on PairAction success alone — UI binds to a separate signal. Tracked as Q-D66-14 indocs/plans/d66-research-questions.md.Whether— CONFIRMED: echo verbatim works. GAMBIT copies the request'scoreDeviceVersionecho must match request verbatimCoreDevice.coreDeviceVersionxpc sub-dict viadict_set_value(opaque pass-through).
The success criterion has been redefined: "Pair button disappears in
Xcode after the click without CDS crash" was achieved partially —
no crash, decoder fully accepts the Output (error nil), but the UI
spinner state is the new gate (Q-D66-14).
Implementation outline¶
Full ~60-line C skeleton in
notes/iosmux-gambit-design-decisions.md §9 (gitignored, host-only
reference). Summary: new file inject/iosmux_gambit.m with
gambit_install_hook() (called from iosmux_inject_init after
S1.B install) + gambit_invoke_anyof_hook() (the actual
intercept) + gambit_intercept_actions[] (the 15-action allow-list).
Cross-references¶
notes/iosmux-mercury-action-design-research.md— round 1 research: hook target, reply path, output Void verdict, endpoint role, Acquire trigger conditions. Gitignored.notes/iosmux-gambit-design-decisions.md— round 2 research: hook mechanism, allow-list, failure mode, output bytes, echo strategy, coexistence + concrete C skeleton. Gitignored.mercury-envelope-empirical.md— empirical Mercury envelope shapes (Envelope A Codable + Envelope B action invocation).pair-button-and-cfnetwork.md§"Pair button gating" — why AcquireDeviceUsageAssertion success matters.action-interception-full-picture.md— historical synthesis with!!! warningadmonitions on the partially-superseded claims.stage2.mdPhase D.6.6-impl sub-task 3 — the parent work item.- ADR-0009 §Consequences — the 15-action static-success set.
- ADR-0006 — the discipline this design follows.