@hraness/ghostget 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (500) hide show
  1. package/CHANGELOG.md +458 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1359 -0
  4. package/bunfig.toml +2 -0
  5. package/dist/apple-photos-client.js +704 -0
  6. package/dist/beeper-client.js +4602 -0
  7. package/dist/client.js +1605 -0
  8. package/dist/index-26yq8q16.js +31 -0
  9. package/dist/index-9t13b4k7.js +508 -0
  10. package/dist/index-axwm9b3v.js +5 -0
  11. package/dist/index-gwk7rbyj.js +30 -0
  12. package/dist/index.js +701 -0
  13. package/dist/messaging.js +1130 -0
  14. package/dist/omni-client.js +1406 -0
  15. package/dist/whatsapp-client.js +355 -0
  16. package/docs/imessage-direct-provider.md +106 -0
  17. package/docs/rental-listings.md +94 -0
  18. package/package.json +600 -0
  19. package/skills/ghostget/SKILL.md +251 -0
  20. package/skills/ghostget/agents/openai.yaml +4 -0
  21. package/skills/ghostget/references/adapter-contract.md +181 -0
  22. package/skills/ghostget/references/apple-photos.md +115 -0
  23. package/skills/ghostget/references/article-drafts.md +235 -0
  24. package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
  25. package/skills/ghostget/references/cross-posting-video.md +139 -0
  26. package/skills/ghostget/references/cross-posting.md +82 -0
  27. package/skills/ghostget/references/derivation.md +390 -0
  28. package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
  29. package/skills/ghostget/references/install.md +66 -0
  30. package/skills/ghostget/references/linkedin-adapter.md +442 -0
  31. package/skills/ghostget/references/messaging.md +168 -0
  32. package/skills/ghostget/references/meta-comet-contract.md +153 -0
  33. package/skills/ghostget/references/platform-patterns.md +122 -0
  34. package/skills/ghostget/references/provider-plugins.md +496 -0
  35. package/skills/ghostget/references/reddit-flair.md +78 -0
  36. package/skills/ghostget/references/safety-and-state.md +214 -0
  37. package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
  38. package/skills/ghostget/references/social-platform-routing.md +66 -0
  39. package/skills/ghostget/references/social-profile-stats.md +195 -0
  40. package/skills/ghostget/references/social-video-platform-routing.md +89 -0
  41. package/skills/ghostget/references/whatsapp-adapter.md +101 -0
  42. package/skills/ghostget/references/x-adapter.md +332 -0
  43. package/skills/ghostget/references/x-ai-disclosure.md +71 -0
  44. package/src/apple-photos-cli.ts +639 -0
  45. package/src/apple-photos-client-types.ts +168 -0
  46. package/src/apple-photos-client.ts +201 -0
  47. package/src/apple-photos-contact-evidence.ts +838 -0
  48. package/src/apple-photos-local-source.ts +1131 -0
  49. package/src/args.ts +2053 -0
  50. package/src/article-draft-document.ts +417 -0
  51. package/src/article-draft-embeds.ts +123 -0
  52. package/src/article-draft-images.ts +214 -0
  53. package/src/assets/adapter-template/wrench-adapter.json +40 -0
  54. package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
  55. package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
  56. package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
  57. package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
  58. package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
  59. package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
  60. package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
  61. package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
  62. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
  63. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
  64. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
  65. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
  66. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
  67. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
  68. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
  69. package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
  70. package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
  71. package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
  72. package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
  73. package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
  74. package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
  75. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
  76. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
  77. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
  78. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
  79. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
  80. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
  81. package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
  82. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
  83. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
  84. package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
  85. package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
  86. package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
  87. package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
  88. package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
  89. package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
  90. package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
  91. package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
  92. package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
  93. package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
  94. package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
  95. package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
  96. package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
  97. package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
  98. package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
  99. package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
  100. package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
  101. package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
  102. package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
  103. package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
  104. package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
  105. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
  106. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
  107. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
  108. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
  109. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
  110. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
  111. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
  112. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
  113. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
  114. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
  115. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
  116. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
  117. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
  118. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
  119. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
  120. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
  121. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
  122. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
  123. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
  124. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
  125. package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
  126. package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
  127. package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
  128. package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
  129. package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
  130. package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
  131. package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
  132. package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
  133. package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
  134. package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
  135. package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
  136. package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
  137. package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
  138. package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
  139. package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
  140. package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
  141. package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
  142. package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
  143. package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
  144. package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
  145. package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
  146. package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
  147. package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
  148. package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
  149. package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
  150. package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
  151. package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
  152. package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
  153. package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
  154. package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
  155. package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
  156. package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
  157. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
  158. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
  159. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
  160. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
  161. package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
  162. package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
  163. package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
  164. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
  165. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
  166. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
  167. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
  168. package/src/assets/adapters/x/wrench-adapter.json +749 -0
  169. package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
  170. package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
  171. package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
  172. package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
  173. package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
  174. package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
  175. package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
  176. package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
  177. package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
  178. package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
  179. package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
  180. package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
  181. package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
  182. package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
  183. package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
  184. package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
  185. package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
  186. package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
  187. package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
  188. package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
  189. package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
  190. package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
  191. package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
  192. package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
  193. package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
  194. package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
  195. package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
  196. package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
  197. package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
  198. package/src/assets.d.ts +4 -0
  199. package/src/auth.ts +1157 -0
  200. package/src/beeper-client-types.ts +169 -0
  201. package/src/beeper-client.ts +250 -0
  202. package/src/beeper-contact-interactions-cli.ts +180 -0
  203. package/src/beeper-contact-interactions.ts +1612 -0
  204. package/src/beeper-message-bundle-v1.ts +257 -0
  205. package/src/beeper-message-like-me-cli.ts +84 -0
  206. package/src/beeper-message-like-me-export.ts +2274 -0
  207. package/src/beeper-message-like-me-recovery.ts +931 -0
  208. package/src/beeper-message-like-me-source.ts +4034 -0
  209. package/src/browser-admission.ts +928 -0
  210. package/src/browser-snapshots.ts +260 -0
  211. package/src/browser.ts +3873 -0
  212. package/src/canonical-json.ts +60 -0
  213. package/src/catalog-cli.ts +638 -0
  214. package/src/cli.ts +208 -0
  215. package/src/client-types.ts +301 -0
  216. package/src/client.ts +2726 -0
  217. package/src/confirmed-write-failure.ts +44 -0
  218. package/src/confirmed-write-model.ts +72 -0
  219. package/src/confirmed-write-platform.ts +1055 -0
  220. package/src/confirmed-write-program.ts +224 -0
  221. package/src/confirmed-write-runtime.ts +16 -0
  222. package/src/cursor-token.ts +228 -0
  223. package/src/derivation-dnr-readiness.ts +367 -0
  224. package/src/derivation-file-chooser.ts +384 -0
  225. package/src/derivation-network-boundary.ts +835 -0
  226. package/src/derivation-network-guard.ts +782 -0
  227. package/src/derivation-network-proxy-helper.ts +440 -0
  228. package/src/derivation-network-proxy.ts +385 -0
  229. package/src/derive-command-helper.ts +693 -0
  230. package/src/derive-fixtures.ts +343 -0
  231. package/src/derive-review.ts +980 -0
  232. package/src/derive.ts +4754 -0
  233. package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
  234. package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
  235. package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
  236. package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
  237. package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
  238. package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
  239. package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
  240. package/src/ghostget.ts +3672 -0
  241. package/src/gmail-capture.ts +1244 -0
  242. package/src/har-internal.ts +1242 -0
  243. package/src/har.ts +743 -0
  244. package/src/index.ts +218 -0
  245. package/src/invocation-read-platform.ts +55 -0
  246. package/src/invocation-read-program.ts +93 -0
  247. package/src/invocation-read-runtime.ts +11 -0
  248. package/src/linked-device-lifecycle-admission.ts +450 -0
  249. package/src/linked-device-lifecycle-journal.ts +1560 -0
  250. package/src/linked-device-lifecycle-runtime.ts +1602 -0
  251. package/src/local-cli-admission.ts +135 -0
  252. package/src/local-cli-contracts.ts +271 -0
  253. package/src/local-cli-execution.ts +98 -0
  254. package/src/local-cli-surface-contract.ts +1510 -0
  255. package/src/local-cli-tool-identity.ts +336 -0
  256. package/src/media/archive.ts +2325 -0
  257. package/src/media/args.ts +303 -0
  258. package/src/media/cli.ts +287 -0
  259. package/src/media/doctor.ts +475 -0
  260. package/src/media/ffmpeg.ts +1330 -0
  261. package/src/media/http-capture.ts +711 -0
  262. package/src/media/http-probe.ts +593 -0
  263. package/src/media/http.ts +387 -0
  264. package/src/media/index.ts +10 -0
  265. package/src/media/local-transcription.ts +613 -0
  266. package/src/media/lock.ts +321 -0
  267. package/src/media/manifest.ts +1712 -0
  268. package/src/media/metadata.ts +906 -0
  269. package/src/media/process.ts +723 -0
  270. package/src/media/revision.ts +166 -0
  271. package/src/media/runtime-closure.ts +1063 -0
  272. package/src/media/source-router.ts +187 -0
  273. package/src/media/transcriber-config.ts +1122 -0
  274. package/src/media/transcript-persistence-model.ts +15 -0
  275. package/src/media/transcript-persistence-platform.ts +89 -0
  276. package/src/media/transcript-persistence-program.ts +39 -0
  277. package/src/media/transcript-persistence-runtime.ts +24 -0
  278. package/src/media/transcript.ts +616 -0
  279. package/src/media/utf8-order.ts +30 -0
  280. package/src/media/whisper-cpp.ts +464 -0
  281. package/src/media/whisper-language.ts +58 -0
  282. package/src/media/yt-dlp.ts +439 -0
  283. package/src/message-like-me-agentic-messaging.ts +882 -0
  284. package/src/messaging-action-store.ts +793 -0
  285. package/src/messaging-runtime.ts +2136 -0
  286. package/src/messaging-store.ts +539 -0
  287. package/src/messaging-types.ts +1663 -0
  288. package/src/messaging.ts +334 -0
  289. package/src/model.ts +2671 -0
  290. package/src/oauth-google.ts +888 -0
  291. package/src/omni-client-types.ts +306 -0
  292. package/src/omni-client.ts +2225 -0
  293. package/src/omni-limits.ts +34 -0
  294. package/src/omni-model.ts +1614 -0
  295. package/src/omni-request.ts +379 -0
  296. package/src/omni-runtime.ts +1632 -0
  297. package/src/operation-deadline.ts +189 -0
  298. package/src/path-helper.ts +1656 -0
  299. package/src/pinned-https.ts +194 -0
  300. package/src/plan-assets.ts +832 -0
  301. package/src/platform-catalog.ts +1832 -0
  302. package/src/plugins/beeper-linked-device/plugin.ts +310 -0
  303. package/src/plugins/bluesky-web/plugin.ts +178 -0
  304. package/src/plugins/clasificados-web/plugin.ts +66 -0
  305. package/src/plugins/github-web/plugin.ts +80 -0
  306. package/src/plugins/gmail-official/plugin.ts +110 -0
  307. package/src/plugins/hacker-news-web/plugin.ts +52 -0
  308. package/src/plugins/imessage-direct/plugin.ts +195 -0
  309. package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
  310. package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
  311. package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
  312. package/src/plugins/linkedin-official/plugin.ts +84 -0
  313. package/src/plugins/linkedin-web/plugin.ts +412 -0
  314. package/src/plugins/meta-web/plugin.ts +317 -0
  315. package/src/plugins/reddit-web/flair.ts +492 -0
  316. package/src/plugins/reddit-web/plugin.ts +477 -0
  317. package/src/plugins/substack-web/plugin.ts +124 -0
  318. package/src/plugins/tiktok-web/plugin.ts +67 -0
  319. package/src/plugins/twitch-web/plugin.ts +54 -0
  320. package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
  321. package/src/plugins/x-official/plugin.ts +64 -0
  322. package/src/plugins/x-web/plugin.ts +488 -0
  323. package/src/plugins/youtube-web/plugin.ts +101 -0
  324. package/src/portable-run-recovery.ts +466 -0
  325. package/src/process-identity.ts +501 -0
  326. package/src/profile-clone-helper.ts +104 -0
  327. package/src/provider-catalog-views.ts +51 -0
  328. package/src/provider-context.ts +69 -0
  329. package/src/provider-contract-definitions-x.ts +45 -0
  330. package/src/provider-contract-definitions.ts +948 -0
  331. package/src/provider-contract-input-gmail.ts +176 -0
  332. package/src/provider-contract-input-linkedin.ts +77 -0
  333. package/src/provider-contract-input-x.ts +304 -0
  334. package/src/provider-contract-planning.ts +23 -0
  335. package/src/provider-contract-semantic-identity.ts +54 -0
  336. package/src/provider-contracts.ts +251 -0
  337. package/src/provider-http.ts +723 -0
  338. package/src/provider-plugin-auth.ts +36 -0
  339. package/src/provider-plugin-builtins.ts +229 -0
  340. package/src/provider-plugin-cleanup-barrier.ts +229 -0
  341. package/src/provider-plugin-cleanup-execution.ts +79 -0
  342. package/src/provider-plugin-cleanup-resource.ts +470 -0
  343. package/src/provider-plugin-contract-identity.ts +707 -0
  344. package/src/provider-plugin-host.ts +1972 -0
  345. package/src/provider-plugin-identifiers.ts +60 -0
  346. package/src/provider-plugin-invocation-lease.ts +889 -0
  347. package/src/provider-plugin-lifecycle-kernel.ts +566 -0
  348. package/src/provider-plugin-lifecycle.ts +1455 -0
  349. package/src/provider-plugin-module-analysis.ts +336 -0
  350. package/src/provider-plugin-package.ts +2530 -0
  351. package/src/provider-plugin-portable-authority.ts +106 -0
  352. package/src/provider-plugin-portable-catalog.ts +94 -0
  353. package/src/provider-plugin-portable-identity.ts +315 -0
  354. package/src/provider-plugin-portable-runtime.ts +2057 -0
  355. package/src/provider-plugin-protocol.ts +1625 -0
  356. package/src/provider-plugin-registry.ts +3342 -0
  357. package/src/provider-plugin-store.ts +2155 -0
  358. package/src/provider-plugin-test-fixture.ts +5 -0
  359. package/src/provider-plugin.ts +4907 -0
  360. package/src/provider-plugins.generated.ts +43 -0
  361. package/src/provider-plugins.ts +8 -0
  362. package/src/provider-subject.ts +41 -0
  363. package/src/provider.ts +315 -0
  364. package/src/providers/beeper-local-runtime.ts +5426 -0
  365. package/src/providers/beeper-local.ts +2739 -0
  366. package/src/providers/beeper-messaging.ts +462 -0
  367. package/src/providers/beeper-omni.ts +1159 -0
  368. package/src/providers/bluesky-web-runtime.ts +2895 -0
  369. package/src/providers/bluesky-web.ts +1614 -0
  370. package/src/providers/clasificados-web-runtime.ts +255 -0
  371. package/src/providers/clasificados-web.ts +503 -0
  372. package/src/providers/contact-projection.ts +301 -0
  373. package/src/providers/github-read-model.ts +94 -0
  374. package/src/providers/github-read-platform.ts +165 -0
  375. package/src/providers/github-read-program.ts +97 -0
  376. package/src/providers/github-web-runtime.ts +320 -0
  377. package/src/providers/github-web.ts +403 -0
  378. package/src/providers/gmail-api.ts +2834 -0
  379. package/src/providers/gmail-omni.ts +830 -0
  380. package/src/providers/gmail.ts +1130 -0
  381. package/src/providers/hacker-news-web-runtime.ts +220 -0
  382. package/src/providers/hacker-news-web.ts +809 -0
  383. package/src/providers/imessage-direct-install.ts +354 -0
  384. package/src/providers/imessage-direct-messaging.ts +532 -0
  385. package/src/providers/imessage-direct-omni.ts +400 -0
  386. package/src/providers/imessage-direct-runtime.ts +1498 -0
  387. package/src/providers/imessage-direct.ts +351 -0
  388. package/src/providers/instagram-video-foundations.ts +913 -0
  389. package/src/providers/instagram-web-profile-browser.ts +600 -0
  390. package/src/providers/iso-bmff.ts +338 -0
  391. package/src/providers/linkedin-company-platform.ts +42 -0
  392. package/src/providers/linkedin-company-program.ts +88 -0
  393. package/src/providers/linkedin-contact-failure.ts +84 -0
  394. package/src/providers/linkedin-contact-platform.ts +45 -0
  395. package/src/providers/linkedin-contact-program.ts +91 -0
  396. package/src/providers/linkedin-profile-activity-failure.ts +76 -0
  397. package/src/providers/linkedin-profile-activity-platform.ts +41 -0
  398. package/src/providers/linkedin-profile-activity-program.ts +67 -0
  399. package/src/providers/linkedin-read-failure.ts +52 -0
  400. package/src/providers/linkedin-self-platform.ts +53 -0
  401. package/src/providers/linkedin-self-program.ts +122 -0
  402. package/src/providers/linkedin-web-article-browser.ts +794 -0
  403. package/src/providers/linkedin-web-bootstrap.ts +146 -0
  404. package/src/providers/linkedin-web-contact.ts +830 -0
  405. package/src/providers/linkedin-web-feed-browser.ts +832 -0
  406. package/src/providers/linkedin-web-feed.ts +864 -0
  407. package/src/providers/linkedin-web-post-browser.ts +653 -0
  408. package/src/providers/linkedin-web-profile-browser.ts +683 -0
  409. package/src/providers/linkedin-web-runtime.ts +2388 -0
  410. package/src/providers/linkedin-web.ts +3305 -0
  411. package/src/providers/linkedin.ts +1885 -0
  412. package/src/providers/meta-bootstrap.ts +1043 -0
  413. package/src/providers/meta-facebook-group.ts +690 -0
  414. package/src/providers/meta-marketplace-relay.ts +747 -0
  415. package/src/providers/meta-omni.ts +373 -0
  416. package/src/providers/meta-relay-bundle.ts +1049 -0
  417. package/src/providers/meta-web-descriptors.ts +1740 -0
  418. package/src/providers/meta-web-runtime.ts +3388 -0
  419. package/src/providers/meta-web.ts +2931 -0
  420. package/src/providers/read-failure.ts +139 -0
  421. package/src/providers/reddit-omni.ts +462 -0
  422. package/src/providers/reddit-web-runtime.ts +1929 -0
  423. package/src/providers/reddit-web.ts +1611 -0
  424. package/src/providers/rental-listings.ts +460 -0
  425. package/src/providers/substack-omni.ts +406 -0
  426. package/src/providers/substack-video-mp4.ts +33 -0
  427. package/src/providers/substack-web-runtime.ts +2812 -0
  428. package/src/providers/substack-web.ts +1502 -0
  429. package/src/providers/tiktok-video-mp4.ts +33 -0
  430. package/src/providers/tiktok-web-runtime.ts +770 -0
  431. package/src/providers/tiktok-web.ts +2786 -0
  432. package/src/providers/twitch-web-runtime.ts +226 -0
  433. package/src/providers/twitch-web.ts +274 -0
  434. package/src/providers/whatsapp-account-identity.ts +55 -0
  435. package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
  436. package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
  437. package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
  438. package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
  439. package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
  440. package/src/providers/whatsapp-messaging.ts +132 -0
  441. package/src/providers/whatsapp-omni.ts +586 -0
  442. package/src/providers/whatsapp-web-runtime.ts +3108 -0
  443. package/src/providers/whatsapp-web.ts +1037 -0
  444. package/src/providers/x-image-provenance.ts +523 -0
  445. package/src/providers/x-made-with-ai.ts +106 -0
  446. package/src/providers/x-transaction-id.ts +413 -0
  447. package/src/providers/x-web-runtime.ts +3485 -0
  448. package/src/providers/x-web.ts +2332 -0
  449. package/src/providers/x.ts +1889 -0
  450. package/src/providers/youtube-web-runtime.ts +1400 -0
  451. package/src/providers/youtube-web.ts +1221 -0
  452. package/src/read-admission-runtime.ts +91 -0
  453. package/src/read-client.ts +521 -0
  454. package/src/read-effect-platform.ts +7 -0
  455. package/src/read-effect-runtime.ts +14 -0
  456. package/src/read-effect.ts +52 -0
  457. package/src/read-projection-admission.ts +866 -0
  458. package/src/read-projections.ts +3151 -0
  459. package/src/recovery.ts +1492 -0
  460. package/src/reviewed-template.ts +146 -0
  461. package/src/run-journal.ts +1272 -0
  462. package/src/runtime.ts +5116 -0
  463. package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
  464. package/src/scripts/install-whatsapp-protocol.sh +232 -0
  465. package/src/scripts/recover-install-stages.ts +1491 -0
  466. package/src/scripts/resolve-state-home.ts +30 -0
  467. package/src/scripts/scaffold-web-provider.ts +973 -0
  468. package/src/scripts/sync-bundled-adapters.ts +695 -0
  469. package/src/session-secrets.ts +1183 -0
  470. package/src/state-helper.bunfig.toml +2 -0
  471. package/src/state-helper.ts +2914 -0
  472. package/src/storage.ts +3842 -0
  473. package/src/transport-policy.ts +2 -0
  474. package/src/usage.ts +182 -0
  475. package/src/version.ts +2 -0
  476. package/src/web-session-authentication-policy.ts +136 -0
  477. package/src/web-session-cleanup-admission.ts +2344 -0
  478. package/src/web-session-cleanup-join.ts +99 -0
  479. package/src/web-session-client.ts +996 -0
  480. package/src/web-session-contract-definitions.ts +820 -0
  481. package/src/web-session-contract-planning.ts +42 -0
  482. package/src/web-session-contracts.ts +228 -0
  483. package/src/web-session-cookies.ts +500 -0
  484. package/src/web-session-execution.ts +385 -0
  485. package/src/web-session-http.ts +373 -0
  486. package/src/web-session-read-errors.ts +19 -0
  487. package/src/web-session-read-runtime.ts +124 -0
  488. package/src/web-session-recovery.ts +818 -0
  489. package/src/web-session-sites.ts +58 -0
  490. package/src/web-session-template.ts +994 -0
  491. package/src/web-session.ts +93 -0
  492. package/src/whatsapp-client-binding.ts +21 -0
  493. package/src/whatsapp-client-types.ts +75 -0
  494. package/src/whatsapp-client.ts +395 -0
  495. package/src/whatsapp-export-coordinate.ts +17 -0
  496. package/src/whatsapp-message-bundle-v2.ts +195 -0
  497. package/src/whatsapp-message-like-me-cli.ts +195 -0
  498. package/src/whatsapp-message-like-me-export.ts +1072 -0
  499. package/src/whatsapp-message-like-me-source.ts +1012 -0
  500. package/tsconfig.json +67 -0
@@ -0,0 +1,1430 @@
1
+ From 292db82d89293867ef847a2875667fea0fdd5dc1 Mon Sep 17 00:00:00 2001
2
+ From: 0thernet <ben@substrate.run>
3
+ Date: Thu, 27 Aug 2026 16:32:47 -0400
4
+ Subject: [PATCH] fix: keep AppleScript send payloads out of child argv
5
+
6
+ ---
7
+ CHANGELOG.md | 10 +
8
+ .../IMsgCore/AppleScriptPrivatePayload.swift | 485 ++++++++++++++++++
9
+ .../IMsgCore/AppleScriptSendTransport.swift | 97 ++--
10
+ Sources/IMsgCore/MessageSender.swift | 20 +-
11
+ ...AppleScriptPrivatePayloadTestSupport.swift | 90 ++++
12
+ .../AppleScriptPrivatePayloadTests.swift | 455 ++++++++++++++++
13
+ docs/private-applescript-transport.md | 107 ++++
14
+ docs/rpc.md | 14 +
15
+ docs/send.md | 9 +-
16
+ 9 files changed, 1247 insertions(+), 40 deletions(-)
17
+ create mode 100644 Sources/IMsgCore/AppleScriptPrivatePayload.swift
18
+ create mode 100644 Tests/IMsgCoreTests/AppleScriptPrivatePayloadTestSupport.swift
19
+ create mode 100644 Tests/IMsgCoreTests/AppleScriptPrivatePayloadTests.swift
20
+ create mode 100644 docs/private-applescript-transport.md
21
+
22
+ diff --git a/CHANGELOG.md b/CHANGELOG.md
23
+ index e236f77..dcf90d8 100644
24
+ --- a/CHANGELOG.md
25
+ +++ b/CHANGELOG.md
26
+ @@ -1,5 +1,15 @@
27
+ # Changelog
28
+
29
+ +## Unreleased
30
+ +
31
+ +### Security
32
+ +
33
+ +- Keep AppleScript send recipients, message bodies, service selection, attachment paths, chat targets, and routing flags out of the nested `osascript` process arguments. Each send now uses a random owner-only payload directory with checked `0600` field files, immutable namespace sealing for the child lifetime, and cleanup only after the child has been reaped.
34
+ +
35
+ +### Documentation
36
+ +
37
+ +- Clarify that AppleScript itself returns no message GUID. A GUID in CLI or RPC output comes from the separately observed outgoing `chat.db` row, and a missing row remains an uncertain, no-retry result.
38
+ +
39
+ ## 0.14.1 - 2026-08-11
40
+
41
+ **Highlight:** search now finds messages whose text lives only in the rich-text
42
+ diff --git a/Sources/IMsgCore/AppleScriptPrivatePayload.swift b/Sources/IMsgCore/AppleScriptPrivatePayload.swift
43
+ new file mode 100644
44
+ index 0000000..b733d2e
45
+ --- /dev/null
46
+ +++ b/Sources/IMsgCore/AppleScriptPrivatePayload.swift
47
+ @@ -0,0 +1,485 @@
48
+ +import Foundation
49
+ +
50
+ +#if canImport(Darwin)
51
+ + import Darwin
52
+ +#elseif canImport(Glibc)
53
+ + import Glibc
54
+ +#endif
55
+ +
56
+ +/// A short-lived, owner-only payload consumed by the AppleScript send child.
57
+ +///
58
+ +/// Messages can carry arbitrary private text. None of it belongs in a process
59
+ +/// argument, where unrelated same-user process inspection can expose it. The
60
+ +/// parent writes each field to an independently checked file and gives
61
+ +/// `osascript` only this directory's random locator.
62
+ +final class AppleScriptPrivatePayload {
63
+ + static let fieldNames = [
64
+ + "recipient",
65
+ + "text",
66
+ + "service",
67
+ + "attachment-path",
68
+ + "use-attachment",
69
+ + "chat-target",
70
+ + "use-chat",
71
+ + ]
72
+ + private static let captureNames = ["stdout", "stderr"]
73
+ +
74
+ + let directoryURL: URL
75
+ +
76
+ + private let expectedOwner: uid_t
77
+ + private let directoryFD: Int32
78
+ + private let directoryIdentity: FileIdentity
79
+ + private let fieldDescriptors: [String: Int32]
80
+ + private let fieldIdentities: [String: FileIdentity]
81
+ + private let fieldContents: [String: Data]
82
+ + private var sealed = false
83
+ + private var cleaned = false
84
+ +
85
+ + private struct FileIdentity: Equatable {
86
+ + let device: UInt64
87
+ + let inode: UInt64
88
+ + let owner: uid_t
89
+ + let mode: mode_t
90
+ + let links: UInt64
91
+ + let size: Int64
92
+ +
93
+ + init(_ info: stat) {
94
+ + device = UInt64(info.st_dev)
95
+ + inode = UInt64(info.st_ino)
96
+ + owner = info.st_uid
97
+ + mode = info.st_mode
98
+ + links = UInt64(info.st_nlink)
99
+ + size = Int64(info.st_size)
100
+ + }
101
+ + }
102
+ +
103
+ + private init(
104
+ + directoryURL: URL,
105
+ + expectedOwner: uid_t,
106
+ + directoryFD: Int32,
107
+ + directoryIdentity: FileIdentity,
108
+ + fieldDescriptors: [String: Int32],
109
+ + fieldIdentities: [String: FileIdentity],
110
+ + fieldContents: [String: Data]
111
+ + ) {
112
+ + self.directoryURL = directoryURL
113
+ + self.expectedOwner = expectedOwner
114
+ + self.directoryFD = directoryFD
115
+ + self.directoryIdentity = directoryIdentity
116
+ + self.fieldDescriptors = fieldDescriptors
117
+ + self.fieldIdentities = fieldIdentities
118
+ + self.fieldContents = fieldContents
119
+ + }
120
+ +
121
+ + deinit {
122
+ + cleanup()
123
+ + }
124
+ +
125
+ + static func create(
126
+ + arguments: [String],
127
+ + expectedOwner: uid_t = geteuid(),
128
+ + temporaryRoot: URL = FileManager.default.temporaryDirectory
129
+ + ) throws -> AppleScriptPrivatePayload {
130
+ + guard arguments.count == fieldNames.count else {
131
+ + throw PrivatePayloadError.invalidFieldCount
132
+ + }
133
+ +
134
+ + let canonicalRoot = temporaryRoot.resolvingSymlinksInPath()
135
+ + let template =
136
+ + canonicalRoot
137
+ + .appendingPathComponent("imsg-private-send.XXXXXXXX", isDirectory: true)
138
+ + .path
139
+ + var templateBytes = Array(template.utf8CString)
140
+ + let createdPath: String? = templateBytes.withUnsafeMutableBufferPointer { buffer in
141
+ + guard let baseAddress = buffer.baseAddress, mkdtemp(baseAddress) != nil else { return nil }
142
+ + return String(cString: baseAddress)
143
+ + }
144
+ + guard let createdPath else {
145
+ + throw PrivatePayloadError.createDirectory
146
+ + }
147
+ +
148
+ + let directoryURL = URL(fileURLWithPath: createdPath, isDirectory: true)
149
+ + var shouldRemoveDirectory = true
150
+ + defer {
151
+ + if shouldRemoveDirectory {
152
+ + try? FileManager.default.removeItem(at: directoryURL)
153
+ + }
154
+ + }
155
+ +
156
+ + let directoryFD = open(
157
+ + createdPath,
158
+ + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC
159
+ + )
160
+ + guard directoryFD >= 0 else {
161
+ + throw PrivatePayloadError.openDirectory
162
+ + }
163
+ + var shouldCloseDirectory = true
164
+ + defer {
165
+ + if shouldCloseDirectory { close(directoryFD) }
166
+ + }
167
+ +
168
+ + guard fchmod(directoryFD, 0o700) == 0 else {
169
+ + throw PrivatePayloadError.secureDirectory
170
+ + }
171
+ + let directoryIdentity = try checkedDirectoryIdentity(
172
+ + descriptor: directoryFD,
173
+ + expectedOwner: expectedOwner
174
+ + )
175
+ +
176
+ + var descriptors: [String: Int32] = [:]
177
+ + var identities: [String: FileIdentity] = [:]
178
+ + var contents: [String: Data] = [:]
179
+ + var shouldCloseFields = true
180
+ + defer {
181
+ + if shouldCloseFields {
182
+ + for descriptor in descriptors.values { close(descriptor) }
183
+ + }
184
+ + }
185
+ + for (name, value) in zip(fieldNames, arguments) {
186
+ + let content = Data(value.utf8)
187
+ + let descriptor = openat(
188
+ + directoryFD,
189
+ + name,
190
+ + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC,
191
+ + 0o600
192
+ + )
193
+ + guard descriptor >= 0 else {
194
+ + throw PrivatePayloadError.createField
195
+ + }
196
+ + descriptors[name] = descriptor
197
+ +
198
+ + guard fchmod(descriptor, 0o600) == 0 else {
199
+ + throw PrivatePayloadError.secureField
200
+ + }
201
+ + try writeAll(content, to: descriptor)
202
+ + guard fsync(descriptor) == 0 else {
203
+ + throw PrivatePayloadError.writeField
204
+ + }
205
+ + identities[name] = try checkedFileIdentity(
206
+ + descriptor: descriptor,
207
+ + expectedOwner: expectedOwner,
208
+ + expectedSize: content.count
209
+ + )
210
+ + contents[name] = content
211
+ + }
212
+ +
213
+ + shouldCloseDirectory = false
214
+ + shouldCloseFields = false
215
+ + shouldRemoveDirectory = false
216
+ + return AppleScriptPrivatePayload(
217
+ + directoryURL: directoryURL,
218
+ + expectedOwner: expectedOwner,
219
+ + directoryFD: directoryFD,
220
+ + directoryIdentity: directoryIdentity,
221
+ + fieldDescriptors: descriptors,
222
+ + fieldIdentities: identities,
223
+ + fieldContents: contents
224
+ + )
225
+ + }
226
+ +}
227
+ +
228
+ +extension AppleScriptPrivatePayload {
229
+ + /// Re-check every pathname immediately before launch. The identity checks
230
+ + /// reject root replacement, field replacement, symlinks, hard links,
231
+ + /// ownership changes, and permission changes.
232
+ + func validateForLaunch() throws {
233
+ + var pathInfo = stat()
234
+ + guard lstat(directoryURL.path, &pathInfo) == 0 else {
235
+ + throw PrivatePayloadError.substitutedDirectory
236
+ + }
237
+ + let pathIdentity = FileIdentity(pathInfo)
238
+ + guard Self.isSecureDirectory(pathIdentity, expectedOwner: expectedOwner),
239
+ + Self.sameObject(pathIdentity, directoryIdentity)
240
+ + else {
241
+ + throw PrivatePayloadError.substitutedDirectory
242
+ + }
243
+ +
244
+ + let pathFD = open(
245
+ + directoryURL.path,
246
+ + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC
247
+ + )
248
+ + guard pathFD >= 0 else {
249
+ + throw PrivatePayloadError.substitutedDirectory
250
+ + }
251
+ + defer { close(pathFD) }
252
+ + let reopenedDirectory = try Self.checkedDirectoryIdentity(
253
+ + descriptor: pathFD,
254
+ + expectedOwner: expectedOwner
255
+ + )
256
+ + guard Self.sameObject(reopenedDirectory, directoryIdentity) else {
257
+ + throw PrivatePayloadError.substitutedDirectory
258
+ + }
259
+ +
260
+ + let pinnedDirectory = try Self.checkedDirectoryIdentity(
261
+ + descriptor: directoryFD,
262
+ + expectedOwner: expectedOwner
263
+ + )
264
+ + guard Self.sameObject(pinnedDirectory, directoryIdentity) else {
265
+ + throw PrivatePayloadError.substitutedDirectory
266
+ + }
267
+ +
268
+ + for name in Self.fieldNames {
269
+ + guard let expected = fieldIdentities[name],
270
+ + let expectedContent = fieldContents[name],
271
+ + let pinnedDescriptor = fieldDescriptors[name]
272
+ + else {
273
+ + throw PrivatePayloadError.missingField
274
+ + }
275
+ + let pinned = try Self.checkedFileIdentity(
276
+ + descriptor: pinnedDescriptor,
277
+ + expectedOwner: expectedOwner,
278
+ + expectedSize: Int(expected.size)
279
+ + )
280
+ + guard pinned == expected else {
281
+ + throw PrivatePayloadError.substitutedField
282
+ + }
283
+ + let descriptor = openat(
284
+ + pathFD,
285
+ + name,
286
+ + O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC
287
+ + )
288
+ + guard descriptor >= 0 else {
289
+ + throw PrivatePayloadError.substitutedField
290
+ + }
291
+ + defer { close(descriptor) }
292
+ + let current = try Self.checkedFileIdentity(
293
+ + descriptor: descriptor,
294
+ + expectedOwner: expectedOwner,
295
+ + expectedSize: Int(expected.size)
296
+ + )
297
+ + guard current == expected else {
298
+ + throw PrivatePayloadError.substitutedField
299
+ + }
300
+ + guard try Self.readAll(from: descriptor) == expectedContent else {
301
+ + throw PrivatePayloadError.substitutedField
302
+ + }
303
+ + }
304
+ + }
305
+ +
306
+ + /// Seal the namespace between the final identity check and child exit. On
307
+ + /// Darwin, user-immutable field and directory flags reject ordinary
308
+ + /// replacement or editing through a discovered payload path. This is not an
309
+ + /// isolation boundary against a compromised process running as the same
310
+ + /// user, which can deliberately clear user flags. The parent retains pinned
311
+ + /// descriptors so it can unseal during cleanup.
312
+ + func sealForLaunch() throws {
313
+ + sealed = true
314
+ + #if canImport(Darwin)
315
+ + for descriptor in fieldDescriptors.values {
316
+ + guard fchflags(descriptor, UInt32(UF_IMMUTABLE)) == 0 else {
317
+ + throw PrivatePayloadError.sealField
318
+ + }
319
+ + }
320
+ + guard fchflags(directoryFD, UInt32(UF_IMMUTABLE)) == 0 else {
321
+ + throw PrivatePayloadError.sealDirectory
322
+ + }
323
+ + guard Self.hasImmutableFlag(descriptor: directoryFD) else {
324
+ + throw PrivatePayloadError.sealDirectory
325
+ + }
326
+ + for descriptor in fieldDescriptors.values {
327
+ + guard Self.hasImmutableFlag(descriptor: descriptor) else {
328
+ + throw PrivatePayloadError.sealField
329
+ + }
330
+ + }
331
+ + #endif
332
+ + }
333
+ +
334
+ + /// Create an output sink under the same private root without resolving any
335
+ + /// caller-controlled pathname.
336
+ + func makeCaptureFile(named name: String) throws -> FileHandle {
337
+ + guard !name.isEmpty, !name.contains("/"), !Self.fieldNames.contains(name) else {
338
+ + throw PrivatePayloadError.invalidCaptureName
339
+ + }
340
+ + let descriptor = openat(
341
+ + directoryFD,
342
+ + name,
343
+ + O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC,
344
+ + 0o600
345
+ + )
346
+ + guard descriptor >= 0 else {
347
+ + throw PrivatePayloadError.createCapture
348
+ + }
349
+ + guard fchmod(descriptor, 0o600) == 0 else {
350
+ + close(descriptor)
351
+ + throw PrivatePayloadError.secureCapture
352
+ + }
353
+ + do {
354
+ + _ = try Self.checkedFileIdentity(
355
+ + descriptor: descriptor,
356
+ + expectedOwner: expectedOwner,
357
+ + expectedSize: 0
358
+ + )
359
+ + } catch {
360
+ + close(descriptor)
361
+ + throw error
362
+ + }
363
+ + return FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
364
+ + }
365
+ +
366
+ + func cleanup() {
367
+ + guard !cleaned else { return }
368
+ + cleaned = true
369
+ +
370
+ + #if canImport(Darwin)
371
+ + if sealed {
372
+ + _ = fchflags(directoryFD, 0)
373
+ + for descriptor in fieldDescriptors.values {
374
+ + _ = fchflags(descriptor, 0)
375
+ + }
376
+ + }
377
+ + #endif
378
+ +
379
+ + // Unlink only known entries through the retained directory descriptor.
380
+ + // This removes private fields even if the directory itself was renamed and
381
+ + // avoids recursively deleting a substituted tree at the public locator.
382
+ + for name in Self.fieldNames + Self.captureNames {
383
+ + _ = unlinkat(directoryFD, name, 0)
384
+ + }
385
+ +
386
+ + // Remove only the exact directory we created. If the path was replaced,
387
+ + // preserving an empty opaque directory is safer than deleting whatever now
388
+ + // occupies the locator. `rmdir` cannot recursively consume a tree swapped
389
+ + // in after this identity check.
390
+ + var pathInfo = stat()
391
+ + if lstat(directoryURL.path, &pathInfo) == 0 {
392
+ + let current = FileIdentity(pathInfo)
393
+ + if (current.mode & S_IFMT) == S_IFDIR,
394
+ + current.owner == expectedOwner,
395
+ + Self.sameObject(current, directoryIdentity)
396
+ + {
397
+ + // Validation deliberately rejects a mode change. Cleanup can safely
398
+ + // repair it through the still-pinned descriptor after proving this is
399
+ + // the same owner and inode, so a failed launch does not leak payloads.
400
+ + _ = fchmod(directoryFD, 0o700)
401
+ + _ = rmdir(directoryURL.path)
402
+ + }
403
+ + }
404
+ + for descriptor in fieldDescriptors.values { close(descriptor) }
405
+ + close(directoryFD)
406
+ + }
407
+ +}
408
+ +
409
+ +extension AppleScriptPrivatePayload {
410
+ + private static func checkedDirectoryIdentity(
411
+ + descriptor: Int32,
412
+ + expectedOwner: uid_t
413
+ + ) throws -> FileIdentity {
414
+ + var info = stat()
415
+ + guard fstat(descriptor, &info) == 0 else {
416
+ + throw PrivatePayloadError.inspectDirectory
417
+ + }
418
+ + let identity = FileIdentity(info)
419
+ + guard isSecureDirectory(identity, expectedOwner: expectedOwner) else {
420
+ + throw PrivatePayloadError.insecureDirectory
421
+ + }
422
+ + return identity
423
+ + }
424
+ +
425
+ + private static func checkedFileIdentity(
426
+ + descriptor: Int32,
427
+ + expectedOwner: uid_t,
428
+ + expectedSize: Int
429
+ + ) throws -> FileIdentity {
430
+ + var info = stat()
431
+ + guard fstat(descriptor, &info) == 0 else {
432
+ + throw PrivatePayloadError.inspectField
433
+ + }
434
+ + let identity = FileIdentity(info)
435
+ + guard (identity.mode & S_IFMT) == S_IFREG,
436
+ + identity.owner == expectedOwner,
437
+ + (identity.mode & 0o7777) == 0o600,
438
+ + identity.links == 1,
439
+ + identity.size == Int64(expectedSize)
440
+ + else {
441
+ + throw PrivatePayloadError.insecureField
442
+ + }
443
+ + return identity
444
+ + }
445
+ +
446
+ + private static func isSecureDirectory(
447
+ + _ identity: FileIdentity,
448
+ + expectedOwner: uid_t
449
+ + ) -> Bool {
450
+ + (identity.mode & S_IFMT) == S_IFDIR
451
+ + && identity.owner == expectedOwner
452
+ + && (identity.mode & 0o7777) == 0o700
453
+ + }
454
+ +
455
+ + private static func sameObject(_ lhs: FileIdentity, _ rhs: FileIdentity) -> Bool {
456
+ + lhs.device == rhs.device && lhs.inode == rhs.inode
457
+ + }
458
+ +
459
+ + #if canImport(Darwin)
460
+ + private static func hasImmutableFlag(descriptor: Int32) -> Bool {
461
+ + var info = stat()
462
+ + return fstat(descriptor, &info) == 0
463
+ + && (info.st_flags & UInt32(UF_IMMUTABLE)) == UInt32(UF_IMMUTABLE)
464
+ + }
465
+ + #endif
466
+ +
467
+ + private static func writeAll(_ data: Data, to descriptor: Int32) throws {
468
+ + try data.withUnsafeBytes { rawBuffer in
469
+ + guard var cursor = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
470
+ + return
471
+ + }
472
+ + var remaining = rawBuffer.count
473
+ + while remaining > 0 {
474
+ + let count: Int
475
+ + #if canImport(Darwin)
476
+ + count = Darwin.write(descriptor, cursor, remaining)
477
+ + #else
478
+ + count = Glibc.write(descriptor, cursor, remaining)
479
+ + #endif
480
+ + if count < 0, errno == EINTR { continue }
481
+ + guard count > 0 else {
482
+ + throw PrivatePayloadError.writeField
483
+ + }
484
+ + cursor = cursor.advanced(by: count)
485
+ + remaining -= count
486
+ + }
487
+ + }
488
+ + }
489
+ +
490
+ + private static func readAll(from descriptor: Int32) throws -> Data {
491
+ + var result = Data()
492
+ + var buffer = [UInt8](repeating: 0, count: 64 * 1024)
493
+ + while true {
494
+ + let count: Int = buffer.withUnsafeMutableBytes { rawBuffer in
495
+ + #if canImport(Darwin)
496
+ + Darwin.read(descriptor, rawBuffer.baseAddress, rawBuffer.count)
497
+ + #else
498
+ + Glibc.read(descriptor, rawBuffer.baseAddress, rawBuffer.count)
499
+ + #endif
500
+ + }
501
+ + if count < 0, errno == EINTR { continue }
502
+ + guard count >= 0 else {
503
+ + throw PrivatePayloadError.readField
504
+ + }
505
+ + if count == 0 { return result }
506
+ + result.append(contentsOf: buffer.prefix(count))
507
+ + }
508
+ + }
509
+ +}
510
+ +
511
+ +private enum PrivatePayloadError: Error {
512
+ + case invalidFieldCount
513
+ + case createDirectory
514
+ + case openDirectory
515
+ + case secureDirectory
516
+ + case inspectDirectory
517
+ + case insecureDirectory
518
+ + case createField
519
+ + case secureField
520
+ + case writeField
521
+ + case readField
522
+ + case inspectField
523
+ + case insecureField
524
+ + case missingField
525
+ + case substitutedDirectory
526
+ + case substitutedField
527
+ + case invalidCaptureName
528
+ + case createCapture
529
+ + case secureCapture
530
+ + case sealField
531
+ + case sealDirectory
532
+ +}
533
+ diff --git a/Sources/IMsgCore/AppleScriptSendTransport.swift b/Sources/IMsgCore/AppleScriptSendTransport.swift
534
+ index 3086d5c..385dee1 100644
535
+ --- a/Sources/IMsgCore/AppleScriptSendTransport.swift
536
+ +++ b/Sources/IMsgCore/AppleScriptSendTransport.swift
537
+ @@ -1,43 +1,51 @@
538
+ import Foundation
539
+
540
+ +#if canImport(Darwin)
541
+ + import Darwin
542
+ +#elseif canImport(Glibc)
543
+ + import Glibc
544
+ +#endif
545
+ +
546
+ enum AppleScriptSendTransport {
547
+ /// Messages can legitimately take longer than the short helper default, but
548
+ /// the child is always bounded.
549
+ static let timeout: TimeInterval = IMsgBridgeProtocol.defaultSendResponseTimeout
550
+
551
+ - static func run(source: String, arguments: [String]) throws {
552
+ + static func run(
553
+ + source: String,
554
+ + arguments: [String],
555
+ + executableURL: URL = URL(fileURLWithPath: "/usr/bin/osascript"),
556
+ + processTimeout: TimeInterval = timeout,
557
+ + expectedOwner: uid_t = geteuid(),
558
+ + beforeLaunch: ((URL) throws -> Void)? = nil,
559
+ + afterLaunch: (() throws -> Void)? = nil
560
+ + ) throws {
561
+ + let payload: AppleScriptPrivatePayload
562
+ + do {
563
+ + payload = try AppleScriptPrivatePayload.create(
564
+ + arguments: arguments,
565
+ + expectedOwner: expectedOwner
566
+ + )
567
+ + } catch {
568
+ + throw failure(.notStarted, "Unable to prepare the private osascript payload.")
569
+ + }
570
+ + defer { payload.cleanup() }
571
+ +
572
+ let process = Process()
573
+ - process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
574
+ - process.arguments = ["-l", "AppleScript", "-"] + arguments
575
+ + process.executableURL = executableURL
576
+ + process.arguments = ["-l", "AppleScript", "-", payload.directoryURL.path]
577
+ let stdinPipe = Pipe()
578
+ - let captureDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(
579
+ - "imsg-osascript-\(UUID().uuidString)", isDirectory: true)
580
+ - let stdoutURL = captureDirectory.appendingPathComponent("stdout")
581
+ - let stderrURL = captureDirectory.appendingPathComponent("stderr")
582
+ + let stdoutHandle: FileHandle
583
+ + let stderrHandle: FileHandle
584
+ do {
585
+ - try FileManager.default.createDirectory(
586
+ - at: captureDirectory,
587
+ - withIntermediateDirectories: true,
588
+ - attributes: [.posixPermissions: 0o700])
589
+ - guard
590
+ - FileManager.default.createFile(atPath: stdoutURL.path, contents: nil),
591
+ - FileManager.default.createFile(atPath: stderrURL.path, contents: nil)
592
+ - else {
593
+ - throw CocoaError(.fileWriteUnknown)
594
+ - }
595
+ - try FileManager.default.setAttributes(
596
+ - [.posixPermissions: 0o600], ofItemAtPath: stdoutURL.path)
597
+ - try FileManager.default.setAttributes(
598
+ - [.posixPermissions: 0o600], ofItemAtPath: stderrURL.path)
599
+ + stdoutHandle = try payload.makeCaptureFile(named: "stdout")
600
+ + stderrHandle = try payload.makeCaptureFile(named: "stderr")
601
+ + try beforeLaunch?(payload.directoryURL)
602
+ + try payload.validateForLaunch()
603
+ + try payload.sealForLaunch()
604
+ + try payload.validateForLaunch()
605
+ } catch {
606
+ - throw failure(.notStarted, "Unable to prepare bounded osascript output capture.")
607
+ - }
608
+ - defer { try? FileManager.default.removeItem(at: captureDirectory) }
609
+ - guard
610
+ - let stdoutHandle = FileHandle(forWritingAtPath: stdoutURL.path),
611
+ - let stderrHandle = FileHandle(forWritingAtPath: stderrURL.path)
612
+ - else {
613
+ - throw failure(.notStarted, "Unable to prepare bounded osascript output capture.")
614
+ + throw failure(.notStarted, "The private osascript payload failed its launch check.")
615
+ }
616
+ defer {
617
+ try? stdoutHandle.close()
618
+ @@ -51,6 +59,16 @@ enum AppleScriptSendTransport {
619
+ } catch {
620
+ throw failure(.notStarted, "osascript could not be launched.")
621
+ }
622
+ + do {
623
+ + try afterLaunch?()
624
+ + } catch {
625
+ + ProcessTimeout.terminate(process)
626
+ + process.waitUntilExit()
627
+ + throw failure(
628
+ + .mayHaveCompleted,
629
+ + "The osascript launch supervisor failed after process launch."
630
+ + )
631
+ + }
632
+ do {
633
+ if let data = source.data(using: .utf8) {
634
+ try stdinPipe.fileHandleForWriting.write(contentsOf: data)
635
+ @@ -64,9 +82,15 @@ enum AppleScriptSendTransport {
636
+ "The osascript input channel failed after process launch."
637
+ )
638
+ }
639
+ - if ProcessTimeout.waitUntilExit(process, timeout: timeout) {
640
+ - throw failure(.mayHaveCompleted, "osascript timed out after \(Int(timeout)) seconds.")
641
+ + if ProcessTimeout.waitUntilExit(process, timeout: processTimeout) {
642
+ + throw failure(
643
+ + .mayHaveCompleted,
644
+ + "osascript timed out after \(Int(processTimeout)) seconds."
645
+ + )
646
+ }
647
+ + // `isRunning == false` is not the ownership boundary we promise here.
648
+ + // Explicitly reap the child before any defer can unseal or remove payloads.
649
+ + process.waitUntilExit()
650
+ try? stdoutHandle.synchronize()
651
+ if process.terminationReason == .uncaughtSignal || process.terminationStatus != 0 {
652
+ throw failure(
653
+ @@ -74,7 +98,16 @@ enum AppleScriptSendTransport {
654
+ "osascript exited without an authoritative delivery phase."
655
+ )
656
+ }
657
+ - let output = (try? String(contentsOf: stdoutURL, encoding: .utf8)) ?? ""
658
+ + let output: String
659
+ + do {
660
+ + try stdoutHandle.seek(toOffset: 0)
661
+ + output = String(data: try stdoutHandle.readToEnd() ?? Data(), encoding: .utf8) ?? ""
662
+ + } catch {
663
+ + throw failure(
664
+ + .mayHaveCompleted,
665
+ + "osascript exited but its structured delivery result could not be read."
666
+ + )
667
+ + }
668
+ switch interpret(output) {
669
+ case .success:
670
+ return
671
+ diff --git a/Sources/IMsgCore/MessageSender.swift b/Sources/IMsgCore/MessageSender.swift
672
+ index fe81a73..195311f 100644
673
+ --- a/Sources/IMsgCore/MessageSender.swift
674
+ +++ b/Sources/IMsgCore/MessageSender.swift
675
+ @@ -185,16 +185,22 @@ public struct MessageSender {
676
+
677
+ private func appleScript() -> String {
678
+ return """
679
+ + on readPrivateField(payloadRoot, fieldName)
680
+ + return read POSIX file (payloadRoot & "/" & fieldName) as «class utf8»
681
+ + end readPrivateField
682
+ +
683
+ on run argv
684
+ set dispatchPhase to "pre_dispatch"
685
+ try
686
+ - set theRecipient to item 1 of argv
687
+ - set theMessage to item 2 of argv
688
+ - set theService to item 3 of argv
689
+ - set theFilePath to item 4 of argv
690
+ - set useAttachment to item 5 of argv
691
+ - set chatId to item 6 of argv
692
+ - set useChat to item 7 of argv
693
+ + if (count of argv) is not 1 then error number -1701
694
+ + set payloadRoot to item 1 of argv
695
+ + set theRecipient to my readPrivateField(payloadRoot, "recipient")
696
+ + set theMessage to my readPrivateField(payloadRoot, "text")
697
+ + set theService to my readPrivateField(payloadRoot, "service")
698
+ + set theFilePath to my readPrivateField(payloadRoot, "attachment-path")
699
+ + set useAttachment to my readPrivateField(payloadRoot, "use-attachment")
700
+ + set chatId to my readPrivateField(payloadRoot, "chat-target")
701
+ + set useChat to my readPrivateField(payloadRoot, "use-chat")
702
+
703
+ tell application "Messages"
704
+ if useChat is "1" then
705
+ diff --git a/Tests/IMsgCoreTests/AppleScriptPrivatePayloadTestSupport.swift b/Tests/IMsgCoreTests/AppleScriptPrivatePayloadTestSupport.swift
706
+ new file mode 100644
707
+ index 0000000..1525212
708
+ --- /dev/null
709
+ +++ b/Tests/IMsgCoreTests/AppleScriptPrivatePayloadTestSupport.swift
710
+ @@ -0,0 +1,90 @@
711
+ +import Darwin
712
+ +import Foundation
713
+ +import Testing
714
+ +
715
+ +@testable import IMsgCore
716
+ +
717
+ +func deliveryFailure(_ operation: () throws -> Void) -> DeliveryFailure? {
718
+ + do {
719
+ + try operation()
720
+ + return nil
721
+ + } catch let failure as DeliveryFailure {
722
+ + return failure
723
+ + } catch {
724
+ + return nil
725
+ + }
726
+ +}
727
+ +
728
+ +func processHasExited(_ processID: pid_t) -> Bool {
729
+ + let deadline = ContinuousClock().now + .seconds(2)
730
+ + let clock = ContinuousClock()
731
+ + while clock.now < deadline {
732
+ + errno = 0
733
+ + if kill(processID, 0) == -1, errno == ESRCH {
734
+ + return true
735
+ + }
736
+ + Thread.sleep(forTimeInterval: 0.02)
737
+ + }
738
+ + return false
739
+ +}
740
+ +
741
+ +func waitForFile(_ url: URL, timeout: TimeInterval) -> Bool {
742
+ + let clock = ContinuousClock()
743
+ + let deadline = clock.now + .seconds(timeout)
744
+ + while clock.now < deadline {
745
+ + if FileManager.default.fileExists(atPath: url.path) { return true }
746
+ + Thread.sleep(forTimeInterval: 0.01)
747
+ + }
748
+ + return false
749
+ +}
750
+ +
751
+ +enum PrivateTransportTestError: Error {
752
+ + case helperDidNotStart
753
+ + case fieldWriteFailed
754
+ +}
755
+ +
756
+ +final class PrivateTransportFixture {
757
+ + let root: URL
758
+ +
759
+ + init() throws {
760
+ + root = FileManager.default.temporaryDirectory
761
+ + .appendingPathComponent("imsg-private-transport-tests-\(UUID().uuidString)")
762
+ + try FileManager.default.createDirectory(
763
+ + at: root,
764
+ + withIntermediateDirectories: false,
765
+ + attributes: [.posixPermissions: 0o700]
766
+ + )
767
+ + }
768
+ +
769
+ + func cleanup() {
770
+ + try? FileManager.default.removeItem(at: root)
771
+ + }
772
+ +
773
+ + func executable(named name: String, source: String) throws -> URL {
774
+ + let url = root.appendingPathComponent(name)
775
+ + try source.write(to: url, atomically: false, encoding: .utf8)
776
+ + try FileManager.default.setAttributes(
777
+ + [.posixPermissions: 0o700],
778
+ + ofItemAtPath: url.path
779
+ + )
780
+ + return url
781
+ + }
782
+ +
783
+ + func successHelper(launchMarker: URL? = nil) throws -> URL {
784
+ + let marker = launchMarker.map { ": > '\($0.path)'" } ?? ":"
785
+ + return try executable(
786
+ + named: "success-\(UUID().uuidString)",
787
+ + source: """
788
+ + #!/bin/sh
789
+ + \(marker)
790
+ + cat >/dev/null
791
+ + printf 'IMSG_RESULT\\tok\\tcompleted\\t0\\n'
792
+ + """
793
+ + )
794
+ + }
795
+ +
796
+ + func mode(of url: URL) throws -> Int {
797
+ + let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
798
+ + return try #require((attributes[.posixPermissions] as? NSNumber)?.intValue)
799
+ + }
800
+ +}
801
+ diff --git a/Tests/IMsgCoreTests/AppleScriptPrivatePayloadTests.swift b/Tests/IMsgCoreTests/AppleScriptPrivatePayloadTests.swift
802
+ new file mode 100644
803
+ index 0000000..88f8657
804
+ --- /dev/null
805
+ +++ b/Tests/IMsgCoreTests/AppleScriptPrivatePayloadTests.swift
806
+ @@ -0,0 +1,455 @@
807
+ +import Darwin
808
+ +import Foundation
809
+ +import Testing
810
+ +
811
+ +@testable import IMsgCore
812
+ +
813
+ +private let privateSendArguments = [
814
+ + "+15550001111-CANARY",
815
+ + "private body CANARY two lines\nsecond line",
816
+ + "imessage-CANARY",
817
+ + "/private/tmp/private-attachment-CANARY",
818
+ + "1",
819
+ + "iMessage;+;private-chat-CANARY",
820
+ + "1",
821
+ +]
822
+ +
823
+ +@Test
824
+ +func osascriptChildArgvContainsOnlyTheOpaquePayloadLocator() throws {
825
+ + let fixture = try PrivateTransportFixture()
826
+ + defer { fixture.cleanup() }
827
+ + let argvCapture = fixture.root.appendingPathComponent("argv")
828
+ + let helper = try fixture.executable(
829
+ + named: "capture-argv",
830
+ + source: """
831
+ + #!/bin/sh
832
+ + : > '\(argvCapture.path)'
833
+ + for argument in "$@"; do
834
+ + printf '%s\\n' "$argument" >> '\(argvCapture.path)'
835
+ + done
836
+ + cat >/dev/null
837
+ + printf 'IMSG_RESULT\\tok\\tcompleted\\t0\\n'
838
+ + """
839
+ + )
840
+ +
841
+ + var payloadRoot: URL?
842
+ + try AppleScriptSendTransport.run(
843
+ + source: "return \"unused\"",
844
+ + arguments: privateSendArguments,
845
+ + executableURL: helper,
846
+ + processTimeout: 2,
847
+ + beforeLaunch: { directory in
848
+ + payloadRoot = directory
849
+ + let directoryMode = try fixture.mode(of: directory)
850
+ + #expect(directoryMode == 0o700)
851
+ + for (name, expectedValue) in zip(
852
+ + AppleScriptPrivatePayload.fieldNames,
853
+ + privateSendArguments
854
+ + ) {
855
+ + let file = directory.appendingPathComponent(name)
856
+ + #expect(try fixture.mode(of: file) == 0o600)
857
+ + #expect(try String(contentsOf: file, encoding: .utf8) == expectedValue)
858
+ + }
859
+ + }
860
+ + )
861
+ +
862
+ + let argv = try String(contentsOf: argvCapture, encoding: .utf8)
863
+ + .split(separator: "\n", omittingEmptySubsequences: false)
864
+ + .dropLast()
865
+ + .map(String.init)
866
+ + #expect(argv.count == 4)
867
+ + #expect(argv[0...2] == ["-l", "AppleScript", "-"])
868
+ + let locator = argv[3]
869
+ + #expect(locator.contains("imsg-private-send."))
870
+ + for privateValue in privateSendArguments where !privateValue.isEmpty {
871
+ + #expect(!argv.contains(privateValue))
872
+ + }
873
+ + let joined = argv.joined(separator: " ")
874
+ + #expect(!joined.contains("CANARY"))
875
+ + #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
876
+ +}
877
+ +
878
+ +@Test
879
+ +func launchedPayloadCannotBeEditedOrReplacedByTheChild() throws {
880
+ + let fixture = try PrivateTransportFixture()
881
+ + defer { fixture.cleanup() }
882
+ + let helper = try fixture.executable(
883
+ + named: "try-to-replace",
884
+ + source: """
885
+ + #!/bin/sh
886
+ + cat >/dev/null
887
+ + if chmod 0644 "$4/text" 2>/dev/null; then exit 71; fi
888
+ + if rm "$4/text" 2>/dev/null; then exit 72; fi
889
+ + if printf 'changed' > "$4/text" 2>/dev/null; then exit 73; fi
890
+ + printf 'IMSG_RESULT\\tok\\tcompleted\\t0\\n'
891
+ + """
892
+ + )
893
+ + var payloadRoot: URL?
894
+ +
895
+ + try AppleScriptSendTransport.run(
896
+ + source: "return \"unused\"",
897
+ + arguments: privateSendArguments,
898
+ + executableURL: helper,
899
+ + processTimeout: 2,
900
+ + beforeLaunch: { payloadRoot = $0 }
901
+ + )
902
+ +
903
+ + #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
904
+ +}
905
+ +
906
+ +@Test(arguments: ["text", "chat-target", "use-chat"])
907
+ +func privatePayloadRejectsSymlinkedFields(_ fieldName: String) throws {
908
+ + let fixture = try PrivateTransportFixture()
909
+ + defer { fixture.cleanup() }
910
+ + let launchMarker = fixture.root.appendingPathComponent("launched")
911
+ + let helper = try fixture.successHelper(launchMarker: launchMarker)
912
+ + let external = fixture.root.appendingPathComponent("external")
913
+ + try Data("outside".utf8).write(to: external)
914
+ + var payloadRoot: URL?
915
+ +
916
+ + let failure = try #require(
917
+ + deliveryFailure {
918
+ + try AppleScriptSendTransport.run(
919
+ + source: "return \"unused\"",
920
+ + arguments: privateSendArguments,
921
+ + executableURL: helper,
922
+ + processTimeout: 2,
923
+ + beforeLaunch: { directory in
924
+ + payloadRoot = directory
925
+ + let field = directory.appendingPathComponent(fieldName)
926
+ + try FileManager.default.removeItem(at: field)
927
+ + try FileManager.default.createSymbolicLink(
928
+ + at: field,
929
+ + withDestinationURL: external
930
+ + )
931
+ + }
932
+ + )
933
+ + }
934
+ + )
935
+ + #expect(failure.disposition == .notStarted)
936
+ + #expect(failure.retrySafe)
937
+ + #expect(!FileManager.default.fileExists(atPath: launchMarker.path))
938
+ + #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
939
+ + #expect(try String(contentsOf: external, encoding: .utf8) == "outside")
940
+ +}
941
+ +
942
+ +@Test
943
+ +func privatePayloadRejectsSameModeSameSizeFileSubstitution() throws {
944
+ + let fixture = try PrivateTransportFixture()
945
+ + defer { fixture.cleanup() }
946
+ + let launchMarker = fixture.root.appendingPathComponent("launched")
947
+ + let helper = try fixture.successHelper(launchMarker: launchMarker)
948
+ + var payloadRoot: URL?
949
+ +
950
+ + let failure = try #require(
951
+ + deliveryFailure {
952
+ + try AppleScriptSendTransport.run(
953
+ + source: "return \"unused\"",
954
+ + arguments: privateSendArguments,
955
+ + executableURL: helper,
956
+ + processTimeout: 2,
957
+ + beforeLaunch: { directory in
958
+ + payloadRoot = directory
959
+ + let field = directory.appendingPathComponent("text")
960
+ + let size = try Data(contentsOf: field).count
961
+ + try FileManager.default.removeItem(at: field)
962
+ + try Data(repeating: UInt8(ascii: "x"), count: size).write(to: field)
963
+ + try FileManager.default.setAttributes(
964
+ + [.posixPermissions: 0o600],
965
+ + ofItemAtPath: field.path
966
+ + )
967
+ + }
968
+ + )
969
+ + }
970
+ + )
971
+ + #expect(failure.disposition == .notStarted)
972
+ + #expect(!FileManager.default.fileExists(atPath: launchMarker.path))
973
+ + #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
974
+ +}
975
+ +
976
+ +@Test
977
+ +func privatePayloadRejectsSameInodeSameSizeContentChanges() throws {
978
+ + let fixture = try PrivateTransportFixture()
979
+ + defer { fixture.cleanup() }
980
+ + let launchMarker = fixture.root.appendingPathComponent("launched")
981
+ + let helper = try fixture.successHelper(launchMarker: launchMarker)
982
+ + var payloadRoot: URL?
983
+ +
984
+ + let failure = try #require(
985
+ + deliveryFailure {
986
+ + try AppleScriptSendTransport.run(
987
+ + source: "return \"unused\"",
988
+ + arguments: privateSendArguments,
989
+ + executableURL: helper,
990
+ + processTimeout: 2,
991
+ + beforeLaunch: { directory in
992
+ + payloadRoot = directory
993
+ + let field = directory.appendingPathComponent("text")
994
+ + let original = try Data(contentsOf: field)
995
+ + let descriptor = open(field.path, O_WRONLY | O_CLOEXEC)
996
+ + #expect(descriptor >= 0)
997
+ + defer { close(descriptor) }
998
+ + let replacement = Data(repeating: UInt8(ascii: "x"), count: original.count)
999
+ + try replacement.withUnsafeBytes { bytes in
1000
+ + guard Darwin.write(descriptor, bytes.baseAddress, bytes.count) == bytes.count else {
1001
+ + throw PrivateTransportTestError.fieldWriteFailed
1002
+ + }
1003
+ + }
1004
+ + #expect(try Data(contentsOf: field).count == original.count)
1005
+ + #expect(try fixture.mode(of: field) == 0o600)
1006
+ + }
1007
+ + )
1008
+ + }
1009
+ + )
1010
+ + #expect(failure.disposition == .notStarted)
1011
+ + #expect(!FileManager.default.fileExists(atPath: launchMarker.path))
1012
+ + #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
1013
+ +}
1014
+ +
1015
+ +@Test
1016
+ +func privatePayloadRejectsRootSymlinkSubstitution() throws {
1017
+ + let fixture = try PrivateTransportFixture()
1018
+ + defer { fixture.cleanup() }
1019
+ + let launchMarker = fixture.root.appendingPathComponent("launched")
1020
+ + let helper = try fixture.successHelper(launchMarker: launchMarker)
1021
+ + let movedRoot = fixture.root.appendingPathComponent("moved-payload")
1022
+ + var originalRoot: URL?
1023
+ + defer {
1024
+ + if let originalRoot {
1025
+ + try? FileManager.default.removeItem(at: originalRoot)
1026
+ + }
1027
+ + try? FileManager.default.removeItem(at: movedRoot)
1028
+ + }
1029
+ +
1030
+ + let failure = try #require(
1031
+ + deliveryFailure {
1032
+ + try AppleScriptSendTransport.run(
1033
+ + source: "return \"unused\"",
1034
+ + arguments: privateSendArguments,
1035
+ + executableURL: helper,
1036
+ + processTimeout: 2,
1037
+ + beforeLaunch: { directory in
1038
+ + originalRoot = directory
1039
+ + try FileManager.default.moveItem(at: directory, to: movedRoot)
1040
+ + try FileManager.default.createSymbolicLink(
1041
+ + at: directory,
1042
+ + withDestinationURL: movedRoot
1043
+ + )
1044
+ + }
1045
+ + )
1046
+ + }
1047
+ + )
1048
+ + #expect(failure.disposition == .notStarted)
1049
+ + #expect(!FileManager.default.fileExists(atPath: launchMarker.path))
1050
+ + #expect(FileManager.default.fileExists(atPath: movedRoot.path))
1051
+ + for name in AppleScriptPrivatePayload.fieldNames {
1052
+ + #expect(!FileManager.default.fileExists(atPath: movedRoot.appendingPathComponent(name).path))
1053
+ + }
1054
+ +}
1055
+ +
1056
+ +@Test(arguments: [0o400, 0o644])
1057
+ +func privatePayloadRejectsFieldModeChanges(_ changedMode: Int) throws {
1058
+ + let fixture = try PrivateTransportFixture()
1059
+ + defer { fixture.cleanup() }
1060
+ + let helper = try fixture.successHelper()
1061
+ + var payloadRoot: URL?
1062
+ +
1063
+ + let failure = try #require(
1064
+ + deliveryFailure {
1065
+ + try AppleScriptSendTransport.run(
1066
+ + source: "return \"unused\"",
1067
+ + arguments: privateSendArguments,
1068
+ + executableURL: helper,
1069
+ + processTimeout: 2,
1070
+ + beforeLaunch: { directory in
1071
+ + payloadRoot = directory
1072
+ + try FileManager.default.setAttributes(
1073
+ + [.posixPermissions: changedMode],
1074
+ + ofItemAtPath: directory.appendingPathComponent("recipient").path
1075
+ + )
1076
+ + }
1077
+ + )
1078
+ + }
1079
+ + )
1080
+ + #expect(failure.disposition == .notStarted)
1081
+ + #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
1082
+ +}
1083
+ +
1084
+ +@Test
1085
+ +func privatePayloadRejectsDirectoryModeChangesAndCleansUp() throws {
1086
+ + let fixture = try PrivateTransportFixture()
1087
+ + defer { fixture.cleanup() }
1088
+ + let helper = try fixture.successHelper()
1089
+ + var payloadRoot: URL?
1090
+ +
1091
+ + let failure = try #require(
1092
+ + deliveryFailure {
1093
+ + try AppleScriptSendTransport.run(
1094
+ + source: "return \"unused\"",
1095
+ + arguments: privateSendArguments,
1096
+ + executableURL: helper,
1097
+ + processTimeout: 2,
1098
+ + beforeLaunch: { directory in
1099
+ + payloadRoot = directory
1100
+ + try FileManager.default.setAttributes(
1101
+ + [.posixPermissions: 0o755],
1102
+ + ofItemAtPath: directory.path
1103
+ + )
1104
+ + }
1105
+ + )
1106
+ + }
1107
+ + )
1108
+ + #expect(failure.disposition == .notStarted)
1109
+ + #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
1110
+ +}
1111
+ +
1112
+ +@Test
1113
+ +func privatePayloadRejectsUnexpectedOwnerBeforeLaunch() throws {
1114
+ + let fixture = try PrivateTransportFixture()
1115
+ + defer { fixture.cleanup() }
1116
+ + let launchMarker = fixture.root.appendingPathComponent("launched")
1117
+ + let helper = try fixture.successHelper(launchMarker: launchMarker)
1118
+ +
1119
+ + let failure = try #require(
1120
+ + deliveryFailure {
1121
+ + try AppleScriptSendTransport.run(
1122
+ + source: "return \"unused\"",
1123
+ + arguments: privateSendArguments,
1124
+ + executableURL: helper,
1125
+ + processTimeout: 2,
1126
+ + expectedOwner: geteuid() &+ 1
1127
+ + )
1128
+ + }
1129
+ + )
1130
+ + #expect(failure.disposition == .notStarted)
1131
+ + #expect(!FileManager.default.fileExists(atPath: launchMarker.path))
1132
+ +}
1133
+ +
1134
+ +@Test
1135
+ +func crashedChildIsReapedAndPrivatePayloadIsRemoved() throws {
1136
+ + let fixture = try PrivateTransportFixture()
1137
+ + defer { fixture.cleanup() }
1138
+ + let pidFile = fixture.root.appendingPathComponent("pid")
1139
+ + let helper = try fixture.executable(
1140
+ + named: "crash",
1141
+ + source: """
1142
+ + #!/bin/sh
1143
+ + printf '%s' "$$" > '\(pidFile.path)'
1144
+ + cat >/dev/null
1145
+ + kill -SEGV "$$"
1146
+ + """
1147
+ + )
1148
+ + var payloadRoot: URL?
1149
+ +
1150
+ + let failure = try #require(
1151
+ + deliveryFailure {
1152
+ + try AppleScriptSendTransport.run(
1153
+ + source: "return \"unused\"",
1154
+ + arguments: privateSendArguments,
1155
+ + executableURL: helper,
1156
+ + processTimeout: 2,
1157
+ + beforeLaunch: { payloadRoot = $0 }
1158
+ + )
1159
+ + }
1160
+ + )
1161
+ + #expect(failure.disposition == .mayHaveCompleted)
1162
+ + let pid = try #require(pid_t(String(contentsOf: pidFile, encoding: .utf8)))
1163
+ + #expect(processHasExited(pid))
1164
+ + #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
1165
+ +}
1166
+ +
1167
+ +@Test
1168
+ +func timedOutChildIsReapedBeforePrivatePayloadCleanup() throws {
1169
+ + let fixture = try PrivateTransportFixture()
1170
+ + defer { fixture.cleanup() }
1171
+ + let pidFile = fixture.root.appendingPathComponent("pid")
1172
+ + let exitMarker = fixture.root.appendingPathComponent("payload-existed-at-exit")
1173
+ + let helper = try fixture.executable(
1174
+ + named: "hang",
1175
+ + source: """
1176
+ + #!/bin/sh
1177
+ + printf '%s' "$$" > '\(pidFile.path)'
1178
+ + trap 'test -d "$4" && : > "\(exitMarker.path)"; exit 0' TERM
1179
+ + cat >/dev/null
1180
+ + while :; do :; done
1181
+ + """
1182
+ + )
1183
+ + var payloadRoot: URL?
1184
+ +
1185
+ + let failure = try #require(
1186
+ + deliveryFailure {
1187
+ + try AppleScriptSendTransport.run(
1188
+ + source: "return \"unused\"",
1189
+ + arguments: privateSendArguments,
1190
+ + executableURL: helper,
1191
+ + processTimeout: 0.2,
1192
+ + beforeLaunch: { payloadRoot = $0 },
1193
+ + afterLaunch: {
1194
+ + guard waitForFile(pidFile, timeout: 5) else {
1195
+ + throw PrivateTransportTestError.helperDidNotStart
1196
+ + }
1197
+ + }
1198
+ + )
1199
+ + }
1200
+ + )
1201
+ + #expect(failure.disposition == .mayHaveCompleted)
1202
+ + #expect(!failure.retrySafe)
1203
+ + let pid = try #require(pid_t(String(contentsOf: pidFile, encoding: .utf8)))
1204
+ + #expect(processHasExited(pid))
1205
+ + #expect(FileManager.default.fileExists(atPath: exitMarker.path))
1206
+ + #expect(payloadRoot.map { !FileManager.default.fileExists(atPath: $0.path) } == true)
1207
+ +}
1208
+ +
1209
+ +@Test
1210
+ +func productionAppleScriptReadsEveryPrivateFieldFromTheLocator() throws {
1211
+ + var source = ""
1212
+ + let sender = MessageSender(runner: { script, _ in source = script })
1213
+ + try sender.send(
1214
+ + MessageSendOptions(
1215
+ + recipient: "+15550001111",
1216
+ + text: "hello",
1217
+ + service: .imessage,
1218
+ + chatGUID: "iMessage;+;opaque-chat",
1219
+ + allowSMSFallback: false
1220
+ + )
1221
+ + )
1222
+ +
1223
+ + #expect(source.contains("if (count of argv) is not 1"))
1224
+ + #expect(source.contains("set payloadRoot to item 1 of argv"))
1225
+ + for fieldName in AppleScriptPrivatePayload.fieldNames {
1226
+ + #expect(source.contains("readPrivateField(payloadRoot, \"\(fieldName)\")"))
1227
+ + }
1228
+ + #expect(!source.contains("item 2 of argv"))
1229
+ + #expect(!source.contains("item 7 of argv"))
1230
+ +}
1231
+ +
1232
+ +@Test
1233
+ +func productionAppleScriptCompilesWithoutExecutingIt() throws {
1234
+ + let fixture = try PrivateTransportFixture()
1235
+ + defer { fixture.cleanup() }
1236
+ + var source = ""
1237
+ + let sender = MessageSender(runner: { script, _ in source = script })
1238
+ + try sender.send(
1239
+ + MessageSendOptions(
1240
+ + recipient: "+15550001111",
1241
+ + text: "hello",
1242
+ + service: .imessage,
1243
+ + allowSMSFallback: false
1244
+ + )
1245
+ + )
1246
+ +
1247
+ + let compiled = fixture.root.appendingPathComponent("send.scpt")
1248
+ + let process = Process()
1249
+ + let input = Pipe()
1250
+ + process.executableURL = URL(fileURLWithPath: "/usr/bin/osacompile")
1251
+ + process.arguments = ["-l", "AppleScript", "-o", compiled.path]
1252
+ + process.standardInput = input
1253
+ + process.standardOutput = FileHandle(forWritingAtPath: "/dev/null")
1254
+ + process.standardError = FileHandle(forWritingAtPath: "/dev/null")
1255
+ + try process.run()
1256
+ + try input.fileHandleForWriting.write(contentsOf: Data(source.utf8))
1257
+ + try input.fileHandleForWriting.close()
1258
+ + #expect(!ProcessTimeout.waitUntilExit(process, timeout: 5))
1259
+ + #expect(process.terminationStatus == 0)
1260
+ + #expect(FileManager.default.fileExists(atPath: compiled.path))
1261
+ +}
1262
+ diff --git a/docs/private-applescript-transport.md b/docs/private-applescript-transport.md
1263
+ new file mode 100644
1264
+ index 0000000..20cac67
1265
+ --- /dev/null
1266
+ +++ b/docs/private-applescript-transport.md
1267
+ @@ -0,0 +1,107 @@
1268
+ +---
1269
+ +title: Private AppleScript transport payload
1270
+ +description: "Security boundary, provenance, evidence, and qualification limits for AppleScript sends."
1271
+ +---
1272
+ +
1273
+ +## Provenance
1274
+ +
1275
+ +This transport change is based narrowly on
1276
+ +[`openclaw/imsg` v0.14.1 at `25beb76c902b0acf2dd7ae392f1b0792f6813240`](https://github.com/openclaw/imsg/tree/25beb76c902b0acf2dd7ae392f1b0792f6813240).
1277
+ +It changes only the published AppleScript send path, its tests, and the
1278
+ +documentation of that path. It does not change bridge sends or provider
1279
+ +routing.
1280
+ +
1281
+ +## Security boundary
1282
+ +
1283
+ +The AppleScript source remains on the child's standard input. The nested
1284
+ +`osascript` process receives four arguments: `-l`, `AppleScript`, `-`, and one
1285
+ +random payload-directory locator. Recipient, text, service, attachment path,
1286
+ +attachment flag, chat target, and chat flag are stored separately in that
1287
+ +directory.
1288
+ +
1289
+ +The parent creates the directory atomically with mode `0700`. It creates every
1290
+ +field with `openat`, `O_EXCL`, and `O_NOFOLLOW`, then requires a regular,
1291
+ +single-link, exact-size file owned by the effective user with mode `0600`.
1292
+ +Immediately before launch it reopens the directory and every field, compares
1293
+ +device and inode identities with retained descriptors, compares every field's
1294
+ +contents with the staged value, and rejects symlinks, replacement, in-place
1295
+ +edits, hard links, ownership changes, and mode changes. On macOS it then applies
1296
+ +the user-immutable flag to the fields and directory for the child lifetime. The
1297
+ +parent retains descriptors, reaps the child, clears the flags, unlinks only
1298
+ +known entries through the retained directory descriptor, and removes the exact
1299
+ +directory it created.
1300
+ +
1301
+ +This design reduces exposure through process arguments and ordinary diagnostic
1302
+ +records. It is not process isolation from malicious software already running as
1303
+ +the same macOS user. Such a process shares owner access to `0600` files and can
1304
+ +deliberately clear user-immutable flags. That stronger threat model requires a
1305
+ +separate privilege or sandbox boundary.
1306
+ +
1307
+ +The transport preserves the existing delivery-state boundary:
1308
+ +
1309
+ +- A payload or launch check that fails before `Process.run()` is `not_started`.
1310
+ +- Lost input after launch, timeout, signal, nonzero exit, malformed output, or
1311
+ + unreadable output is `may_have_completed`.
1312
+ +- It does not infer a retry, add a retry, or convert uncertainty into success.
1313
+ +
1314
+ +SMS fallback remains available to existing callers only for the already
1315
+ +qualified `not_started` case. A caller that requires one iMessage attempt can
1316
+ +set `transport: "applescript"`, `service: "imessage"`, and
1317
+ +`allow_sms_fallback: false`.
1318
+ +
1319
+ +## Receipt limit
1320
+ +
1321
+ +Messages' AppleScript `send` command does not return a message GUID. Its
1322
+ +structured `completed` result proves only that the automation call returned.
1323
+ +The CLI and RPC server obtain any outgoing GUID by separately matching a new
1324
+ +`chat.db` row by chat, text, and a bounded time window. No matching row is
1325
+ +`may_have_completed`; it is not proof that nothing was sent. An unknown GUID
1326
+ +reported as pending by `message.send_status` is also not proof of submission.
1327
+ +
1328
+ +For a caller that launches one fresh supervised `imsg rpc` process per visible
1329
+ +message part, the safe topology is one request, explicit AppleScript transport,
1330
+ +SMS fallback disabled, stdin closed after the request, and the process awaited
1331
+ +through response and exit. This repository's RPC server still supports many
1332
+ +requests per process. The transport patch does not enforce the one-request
1333
+ +topology.
1334
+ +
1335
+ +## Deterministic evidence
1336
+ +
1337
+ +`AppleScriptPrivatePayloadTests` verifies:
1338
+ +
1339
+ +- nested child argv contains no recipient, body, service, attachment path,
1340
+ + chat target, or flag canary;
1341
+ +- the payload directory and files have exact modes and contents;
1342
+ +- symlink, root replacement, field replacement, in-place field editing, owner
1343
+ + mismatch, and mode changes fail before child launch;
1344
+ +- immutable sealing blocks child-side edit, unlink, and replacement;
1345
+ +- crash and timeout paths reap the child and remove the payload;
1346
+ +- timeout cleanup begins only after the child's exit handler observes the
1347
+ + payload still present;
1348
+ +- the complete production AppleScript compiles without executing it.
1349
+ +
1350
+ +Existing delivery, SMS-fallback, RPC fallback-control, queue-state, and
1351
+ +outgoing-row verification tests continue to cover the unchanged typed outcome
1352
+ +contract.
1353
+ +
1354
+ +## Production qualification still required
1355
+ +
1356
+ +This local patch is not a distributable provider by itself. Production use
1357
+ +still requires:
1358
+ +
1359
+ +1. upstream or maintained-fork review of the exact diff;
1360
+ +2. a pinned release build with checksum, signing, notarization, and installer
1361
+ + attestation;
1362
+ +3. macOS CI for the full test suite and Linux CI for the read-only build;
1363
+ +4. argv inspection against the signed release binary, including the nested
1364
+ + `osascript` child;
1365
+ +5. a separately authorized self-chat test for Automation permission, exact
1366
+ + chat routing, outgoing-row/GUID reconciliation, timeout classification, and
1367
+ + no SMS fallback;
1368
+ +6. caller integration that launches one supervised RPC process per visible
1369
+ + message part and never retries `may_have_completed` or `still_in_flight`;
1370
+ +7. an operational rule that treats a missing or ambiguous outgoing row as
1371
+ + unresolved rather than delivered or absent.
1372
+ +
1373
+ +No live message send is part of the deterministic test suite or this
1374
+ +prototype's qualification evidence.
1375
+ diff --git a/docs/rpc.md b/docs/rpc.md
1376
+ index 94cdc43..c788989 100644
1377
+ --- a/docs/rpc.md
1378
+ +++ b/docs/rpc.md
1379
+ @@ -754,6 +754,12 @@ Send and receive verification:
1380
+ {"jsonrpc":"2.0","id":"3","result":{"ok":true,"transport":"applescript","id":1979,"guid":"8DF..."}}
1381
+ ```
1382
+
1383
+ +Require the published AppleScript surface without an automatic SMS retry:
1384
+ +
1385
+ +```json
1386
+ +{"jsonrpc":"2.0","id":"4","method":"send","params":{"chat_guid":"iMessage;+;chat123","text":"hi","service":"imessage","transport":"applescript","allow_sms_fallback":false}}
1387
+ +```
1388
+ +
1389
+ `send` accepts `transport: "auto" | "bridge" | "applescript"`. `auto`
1390
+ uses the IMCore bridge for existing chats when it is running. It falls back to
1391
+ AppleScript only when the bridge is not ready or returns authoritative
1392
+ @@ -761,3 +767,11 @@ AppleScript only when the bridge is not ready or returns authoritative
1393
+ malformed response, or other uncertain post-publication failure never falls
1394
+ back. Use `bridge` when the caller requires private-API delivery and should
1395
+ fail instead of falling back. Replies remain bridge-only.
1396
+ +
1397
+ +For AppleScript sends, the nested `osascript` argv contains only fixed
1398
+ +interpreter switches and a random private-payload locator. Recipient, body,
1399
+ +service, attachment path, chat target, and routing flags are owner-only files
1400
+ +sealed for the child lifetime. AppleScript does not return a message GUID. Any
1401
+ +`guid` in the response came from the independently matched outgoing database
1402
+ +row; failure to observe that row is `may_have_completed`, never a retry-safe
1403
+ +absence.
1404
+ diff --git a/docs/send.md b/docs/send.md
1405
+ index ba2403d..b3d40ce 100644
1406
+ --- a/docs/send.md
1407
+ +++ b/docs/send.md
1408
+ @@ -3,7 +3,7 @@ title: Send
1409
+ description: "Send text and files to direct chats and groups through Messages.app automation, plus standard tapbacks."
1410
+ ---
1411
+
1412
+ -`imsg send` rides Messages' published AppleScript surface — no private send APIs, no IMCore injection. Production sends run in a bounded `/usr/bin/osascript` child so a stuck Messages automation call can be terminated and reaped. Sending requires Automation permission for Messages (see [Permissions](permissions.md)).
1413
+ +`imsg send` rides Messages' published AppleScript surface — no private send APIs, no IMCore injection. Production sends run in a bounded `/usr/bin/osascript` child so a stuck Messages automation call can be terminated and reaped. The recipient, body, service, attachment path, chat target, and routing flags never enter that child's process arguments. They live in checked `0600` files under one random `0700` directory, whose namespace stays immutable until the child exits. Sending requires Automation permission for Messages (see [Permissions](permissions.md)).
1414
+
1415
+ ## Direct sends
1416
+
1417
+ @@ -99,6 +99,13 @@ verification is unchanged.
1418
+
1419
+ The [JSON-RPC `send` method](rpc.md#send) includes the rowid and GUID of the inserted message when available. RPC `send` also accepts `transport` (`auto`, `bridge`, or `applescript`) for callers that want to prefer or require the IMCore bridge.
1420
+
1421
+ +AppleScript's `send` command does not return a message GUID. A GUID reported by
1422
+ +`imsg` is evidence from the separately observed outgoing `chat.db` row, not an
1423
+ +AppleScript receipt. If the row cannot be matched, a text send reports
1424
+ +`may_have_completed` and must not be retried automatically. `message.send_status`
1425
+ +can inspect a GUID only after some transport or database observation supplied
1426
+ +that exact GUID.
1427
+ +
1428
+ ## Tahoe ghost-row protection
1429
+
1430
+ On macOS 26 (Tahoe), Messages.app has a failure mode where AppleScript reports success but writes an empty outgoing SMS row that isn't joined to the target chat. The send looks fine to the caller but never reaches the recipient.