Skip to content

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.mdnotes/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: 0xff50 on x86_64, 0xfeb0 on arm64e (different per arch — use dlsym(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-byte movabs r10, hook_fn + jmp r10. DYLD_INTERPOSE does NOT work for this symbol — Swift internal calls go through direct callq rel32, not __got indirect.

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):

  1. Empty xpc_dictionary (priority 1) — xpc_dictionary_create(NULL, NULL, 0)
  2. Absent CoreDevice.output key (priority 2) — set nothing, only echo envelope keys
  3. 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)

  1. Exact Codable encoding of Void for CoreDevice.output — FALSIFIED: Output is NOT Void. Apple decoder named the actual type on iter 3 (null mode). PairAction's Output is CoreDevice.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 in gambit-pair-action-schema.md.
  2. Whether progress events are required on the input endpoint — PARTIALLY ANSWERED: not required for Apple's decoder to accept the reply (iter 17 confirmed Pairing 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 in docs/plans/d66-research-questions.md.
  3. Whether coreDeviceVersion echo must match request verbatim — CONFIRMED: echo verbatim works. GAMBIT copies the request's CoreDevice.coreDeviceVersion xpc sub-dict via dict_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 !!! warning admonitions on the partially-superseded claims.
  • stage2.md Phase 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.