apex-code 0.0.1-alpha.7 → 0.0.1-alpha.8

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 (256) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +5 -0
  3. package/dist/cli/args.d.ts +3 -1
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +15 -0
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/cli/concurrent-session.d.ts +3 -0
  8. package/dist/cli/concurrent-session.d.ts.map +1 -0
  9. package/dist/cli/concurrent-session.js +13 -0
  10. package/dist/cli/concurrent-session.js.map +1 -0
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +1 -2
  13. package/dist/cli.js.map +1 -1
  14. package/dist/core/agent-session-services.d.ts.map +1 -1
  15. package/dist/core/agent-session-services.js +20 -0
  16. package/dist/core/agent-session-services.js.map +1 -1
  17. package/dist/core/agent-session.d.ts +15 -2
  18. package/dist/core/agent-session.d.ts.map +1 -1
  19. package/dist/core/agent-session.js +9 -6
  20. package/dist/core/agent-session.js.map +1 -1
  21. package/dist/core/auth-storage.d.ts +4 -1
  22. package/dist/core/auth-storage.d.ts.map +1 -1
  23. package/dist/core/auth-storage.js +6 -2
  24. package/dist/core/auth-storage.js.map +1 -1
  25. package/dist/core/bash-executor.d.ts.map +1 -1
  26. package/dist/core/bash-executor.js +10 -9
  27. package/dist/core/bash-executor.js.map +1 -1
  28. package/dist/core/experimental.d.ts +4 -0
  29. package/dist/core/experimental.d.ts.map +1 -1
  30. package/dist/core/experimental.js +4 -0
  31. package/dist/core/experimental.js.map +1 -1
  32. package/dist/core/extensions/types.d.ts +4 -0
  33. package/dist/core/extensions/types.d.ts.map +1 -1
  34. package/dist/core/extensions/types.js.map +1 -1
  35. package/dist/core/http-dispatcher.d.ts +1 -2
  36. package/dist/core/http-dispatcher.d.ts.map +1 -1
  37. package/dist/core/http-dispatcher.js +2 -17
  38. package/dist/core/http-dispatcher.js.map +1 -1
  39. package/dist/core/http-idle-timeout.d.ts +12 -0
  40. package/dist/core/http-idle-timeout.d.ts.map +1 -0
  41. package/dist/core/http-idle-timeout.js +27 -0
  42. package/dist/core/http-idle-timeout.js.map +1 -0
  43. package/dist/core/keybindings.d.ts +24 -0
  44. package/dist/core/keybindings.d.ts.map +1 -1
  45. package/dist/core/lsp/client.d.ts +2 -0
  46. package/dist/core/lsp/client.d.ts.map +1 -1
  47. package/dist/core/lsp/client.js +27 -2
  48. package/dist/core/lsp/client.js.map +1 -1
  49. package/dist/core/model-config.d.ts +5 -0
  50. package/dist/core/model-config.d.ts.map +1 -1
  51. package/dist/core/model-config.js +1 -0
  52. package/dist/core/model-config.js.map +1 -1
  53. package/dist/core/sandbox/child-entry.d.ts.map +1 -1
  54. package/dist/core/sandbox/child-entry.js +8 -1
  55. package/dist/core/sandbox/child-entry.js.map +1 -1
  56. package/dist/core/sandbox/cli-launch.d.ts +3 -0
  57. package/dist/core/sandbox/cli-launch.d.ts.map +1 -1
  58. package/dist/core/sandbox/cli-launch.js +25 -4
  59. package/dist/core/sandbox/cli-launch.js.map +1 -1
  60. package/dist/core/sandbox/cli-supervisor.d.ts.map +1 -1
  61. package/dist/core/sandbox/cli-supervisor.js +53 -13
  62. package/dist/core/sandbox/cli-supervisor.js.map +1 -1
  63. package/dist/core/sandbox/linux-backend.d.ts.map +1 -1
  64. package/dist/core/sandbox/linux-backend.js +15 -0
  65. package/dist/core/sandbox/linux-backend.js.map +1 -1
  66. package/dist/core/sandbox/macos-backend.d.ts.map +1 -1
  67. package/dist/core/sandbox/macos-backend.js +24 -1
  68. package/dist/core/sandbox/macos-backend.js.map +1 -1
  69. package/dist/core/sandbox/rpc/credential-client.d.ts +20 -0
  70. package/dist/core/sandbox/rpc/credential-client.d.ts.map +1 -0
  71. package/dist/core/sandbox/rpc/credential-client.js +170 -0
  72. package/dist/core/sandbox/rpc/credential-client.js.map +1 -0
  73. package/dist/core/sandbox/rpc/credential-proxy.d.ts +44 -0
  74. package/dist/core/sandbox/rpc/credential-proxy.d.ts.map +1 -0
  75. package/dist/core/sandbox/rpc/credential-proxy.js +381 -0
  76. package/dist/core/sandbox/rpc/credential-proxy.js.map +1 -0
  77. package/dist/core/sandbox/supervisor.d.ts +11 -0
  78. package/dist/core/sandbox/supervisor.d.ts.map +1 -1
  79. package/dist/core/sandbox/supervisor.js.map +1 -1
  80. package/dist/core/sandbox/terminal-size.d.ts +40 -0
  81. package/dist/core/sandbox/terminal-size.d.ts.map +1 -0
  82. package/dist/core/sandbox/terminal-size.js +105 -0
  83. package/dist/core/sandbox/terminal-size.js.map +1 -0
  84. package/dist/core/sdk.d.ts +6 -3
  85. package/dist/core/sdk.d.ts.map +1 -1
  86. package/dist/core/sdk.js +16 -4
  87. package/dist/core/sdk.js.map +1 -1
  88. package/dist/core/session-lease.d.ts +31 -0
  89. package/dist/core/session-lease.d.ts.map +1 -0
  90. package/dist/core/session-lease.js +138 -0
  91. package/dist/core/session-lease.js.map +1 -0
  92. package/dist/core/session-manager.d.ts +5 -0
  93. package/dist/core/session-manager.d.ts.map +1 -1
  94. package/dist/core/session-manager.js +3 -3
  95. package/dist/core/session-manager.js.map +1 -1
  96. package/dist/core/settings-manager.d.ts +26 -0
  97. package/dist/core/settings-manager.d.ts.map +1 -1
  98. package/dist/core/settings-manager.js +42 -3
  99. package/dist/core/settings-manager.js.map +1 -1
  100. package/dist/core/slash-commands.d.ts.map +1 -1
  101. package/dist/core/slash-commands.js +2 -0
  102. package/dist/core/slash-commands.js.map +1 -1
  103. package/dist/core/system-prompt.d.ts +2 -2
  104. package/dist/core/system-prompt.d.ts.map +1 -1
  105. package/dist/core/system-prompt.js +47 -62
  106. package/dist/core/system-prompt.js.map +1 -1
  107. package/dist/core/tools/bash.d.ts.map +1 -1
  108. package/dist/core/tools/bash.js +2 -0
  109. package/dist/core/tools/bash.js.map +1 -1
  110. package/dist/core/tools/delegate.d.ts.map +1 -1
  111. package/dist/core/tools/delegate.js +24 -0
  112. package/dist/core/tools/delegate.js.map +1 -1
  113. package/dist/core/tools/edit.d.ts.map +1 -1
  114. package/dist/core/tools/edit.js +2 -0
  115. package/dist/core/tools/edit.js.map +1 -1
  116. package/dist/core/tools/find.d.ts.map +1 -1
  117. package/dist/core/tools/find.js +1 -1
  118. package/dist/core/tools/find.js.map +1 -1
  119. package/dist/core/tools/grep.d.ts +2 -0
  120. package/dist/core/tools/grep.d.ts.map +1 -1
  121. package/dist/core/tools/grep.js +35 -10
  122. package/dist/core/tools/grep.js.map +1 -1
  123. package/dist/core/tools/ls.d.ts +2 -0
  124. package/dist/core/tools/ls.d.ts.map +1 -1
  125. package/dist/core/tools/ls.js +5 -1
  126. package/dist/core/tools/ls.js.map +1 -1
  127. package/dist/core/tools/read.d.ts.map +1 -1
  128. package/dist/core/tools/read.js +2 -0
  129. package/dist/core/tools/read.js.map +1 -1
  130. package/dist/core/tools/web-search-exa.d.ts +33 -0
  131. package/dist/core/tools/web-search-exa.d.ts.map +1 -0
  132. package/dist/core/tools/web-search-exa.js +128 -0
  133. package/dist/core/tools/web-search-exa.js.map +1 -0
  134. package/dist/core/tools/write.d.ts.map +1 -1
  135. package/dist/core/tools/write.js +2 -0
  136. package/dist/core/tools/write.js.map +1 -1
  137. package/dist/core/web-search-provider.d.ts +64 -0
  138. package/dist/core/web-search-provider.d.ts.map +1 -0
  139. package/dist/core/web-search-provider.js +149 -0
  140. package/dist/core/web-search-provider.js.map +1 -0
  141. package/dist/index.d.ts +1 -1
  142. package/dist/index.d.ts.map +1 -1
  143. package/dist/index.js.map +1 -1
  144. package/dist/main.d.ts +1 -0
  145. package/dist/main.d.ts.map +1 -1
  146. package/dist/main.js +20 -1
  147. package/dist/main.js.map +1 -1
  148. package/dist/modes/interactive/components/custom-editor.d.ts +23 -0
  149. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  150. package/dist/modes/interactive/components/custom-editor.js +105 -2
  151. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  152. package/dist/modes/interactive/components/dynamic-border.d.ts +7 -1
  153. package/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  154. package/dist/modes/interactive/components/dynamic-border.js +11 -2
  155. package/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  156. package/dist/modes/interactive/components/extension-selector.d.ts +4 -0
  157. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
  158. package/dist/modes/interactive/components/extension-selector.js +24 -6
  159. package/dist/modes/interactive/components/extension-selector.js.map +1 -1
  160. package/dist/modes/interactive/components/first-time-setup.d.ts.map +1 -1
  161. package/dist/modes/interactive/components/first-time-setup.js +6 -3
  162. package/dist/modes/interactive/components/first-time-setup.js.map +1 -1
  163. package/dist/modes/interactive/components/first-use-hints.d.ts +8 -0
  164. package/dist/modes/interactive/components/first-use-hints.d.ts.map +1 -0
  165. package/dist/modes/interactive/components/first-use-hints.js +22 -0
  166. package/dist/modes/interactive/components/first-use-hints.js.map +1 -0
  167. package/dist/modes/interactive/components/footer.d.ts +12 -0
  168. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  169. package/dist/modes/interactive/components/footer.js +128 -9
  170. package/dist/modes/interactive/components/footer.js.map +1 -1
  171. package/dist/modes/interactive/components/index.d.ts +1 -0
  172. package/dist/modes/interactive/components/index.d.ts.map +1 -1
  173. package/dist/modes/interactive/components/index.js +1 -0
  174. package/dist/modes/interactive/components/index.js.map +1 -1
  175. package/dist/modes/interactive/components/model-selector.d.ts +2 -0
  176. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  177. package/dist/modes/interactive/components/model-selector.js +20 -5
  178. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  179. package/dist/modes/interactive/components/settings-selector.d.ts +3 -1
  180. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  181. package/dist/modes/interactive/components/settings-selector.js +10 -0
  182. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  183. package/dist/modes/interactive/components/splash-header.d.ts +29 -4
  184. package/dist/modes/interactive/components/splash-header.d.ts.map +1 -1
  185. package/dist/modes/interactive/components/splash-header.js +58 -23
  186. package/dist/modes/interactive/components/splash-header.js.map +1 -1
  187. package/dist/modes/interactive/components/tool-execution.d.ts +14 -0
  188. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  189. package/dist/modes/interactive/components/tool-execution.js +113 -35
  190. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  191. package/dist/modes/interactive/components/tool-panel.d.ts +30 -0
  192. package/dist/modes/interactive/components/tool-panel.d.ts.map +1 -0
  193. package/dist/modes/interactive/components/tool-panel.js +130 -0
  194. package/dist/modes/interactive/components/tool-panel.js.map +1 -0
  195. package/dist/modes/interactive/interactive-mode.d.ts +44 -9
  196. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  197. package/dist/modes/interactive/interactive-mode.js +282 -70
  198. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  199. package/dist/modes/interactive/model-catalog-refresh.d.ts +7 -0
  200. package/dist/modes/interactive/model-catalog-refresh.d.ts.map +1 -0
  201. package/dist/modes/interactive/model-catalog-refresh.js +34 -0
  202. package/dist/modes/interactive/model-catalog-refresh.js.map +1 -0
  203. package/dist/modes/interactive/theme/apex.json +62 -63
  204. package/dist/modes/interactive/theme/dark.json +2 -0
  205. package/dist/modes/interactive/theme/light.json +2 -0
  206. package/dist/modes/interactive/theme/theme-controller.d.ts +10 -2
  207. package/dist/modes/interactive/theme/theme-controller.d.ts.map +1 -1
  208. package/dist/modes/interactive/theme/theme-controller.js +25 -11
  209. package/dist/modes/interactive/theme/theme-controller.js.map +1 -1
  210. package/dist/modes/interactive/theme/theme-schema.json +9 -1
  211. package/dist/modes/interactive/theme/theme.d.ts +14 -3
  212. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  213. package/dist/modes/interactive/theme/theme.js +55 -6
  214. package/dist/modes/interactive/theme/theme.js.map +1 -1
  215. package/dist/modes/json-event.d.ts +4 -1
  216. package/dist/modes/json-event.d.ts.map +1 -1
  217. package/dist/modes/json-event.js +5 -2
  218. package/dist/modes/json-event.js.map +1 -1
  219. package/dist/server/create-harness.d.ts.map +1 -1
  220. package/dist/server/create-harness.js +2 -0
  221. package/dist/server/create-harness.js.map +1 -1
  222. package/dist/themes/apex-logo.d.ts +36 -16
  223. package/dist/themes/apex-logo.d.ts.map +1 -1
  224. package/dist/themes/apex-logo.js +32 -28
  225. package/dist/themes/apex-logo.js.map +1 -1
  226. package/dist/utils/tools-manager.d.ts +10 -1
  227. package/dist/utils/tools-manager.d.ts.map +1 -1
  228. package/dist/utils/tools-manager.js +15 -20
  229. package/dist/utils/tools-manager.js.map +1 -1
  230. package/docs/compaction.md +3 -3
  231. package/docs/custom-provider.md +1 -1
  232. package/docs/environment-variables.md +1 -0
  233. package/docs/extensions.md +4 -0
  234. package/docs/json.md +6 -4
  235. package/docs/keybindings.md +8 -2
  236. package/docs/rpc.md +15 -4
  237. package/docs/sdk.md +9 -0
  238. package/docs/settings.md +63 -0
  239. package/docs/terminal-setup.md +23 -2
  240. package/docs/themes.md +24 -3
  241. package/docs/tui.md +2 -2
  242. package/docs/usage.md +3 -2
  243. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  244. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  245. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  246. package/examples/extensions/gondolin/package-lock.json +2 -2
  247. package/examples/extensions/gondolin/package.json +1 -1
  248. package/examples/extensions/sandbox/package-lock.json +2 -2
  249. package/examples/extensions/sandbox/package.json +1 -1
  250. package/examples/extensions/subagent/README.md +2 -0
  251. package/examples/extensions/subagent/agents.ts +40 -9
  252. package/examples/extensions/subagent/index.ts +21 -3
  253. package/examples/extensions/with-deps/package-lock.json +2 -2
  254. package/examples/extensions/with-deps/package.json +1 -1
  255. package/npm-shrinkwrap.json +27 -78
  256. package/package.json +6 -6
@@ -1 +1 @@
1
- {"version":3,"file":"cli-supervisor.js","sourceRoot":"","sources":["../../../src/core/sandbox/cli-supervisor.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAuB,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC;AAC/D,OAAO,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC;AAC/D,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,uBAAuB,EAAuB,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAOxD;;6CAE6C;AAC7C,SAAS,2BAA2B,CAAC,OAAkD,EAAkB;IACxG,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ;QAAE,OAAO,yBAAyB,CAAC,OAAO,CAAC,CAAC;IAC7E,OAAO,yBAAyB,CAAC,OAAO,CAAC,CAAC;AAAA,CAC1C;AAED,MAAM,mBAAmB,GAA2B;IACnD,aAAa,EAAE,2BAA2B;IAC1C,MAAM,EAAE,OAAO,CAAC,MAAM;CACtB,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,OAWxC,EAAmB;IACnB,MAAM,YAAY,GAAG,EAAE,GAAG,mBAAmB,EAAE,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IACzE,MAAM,YAAY,GAAG,mBAAmB,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,YAAY,EAAE,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;IAC/G,IAAI,YAAY,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QACrC,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,0DAA0D,YAAY,CAAC,MAAM,IAAI,CAAC,CAAC;QAC7G,OAAO,CAAC,CAAC;IACV,CAAC;IACD,MAAM,cAAc,GAAG,IAAI,qBAAqB,EAAE,CAAC;IACnD,MAAM,OAAO,GAAG,YAAY,CAAC,aAAa,CAAC,EAAE,cAAc,EAAE,CAAC,CAAC;IAC/D,MAAM,UAAU,GAAG,uBAAuB,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,CAAC,MAAM,EAAE,CAAC,CAAC;IACrF,MAAM,MAAM,GAAG,uBAAuB,CAAC;QACtC,SAAS,EAAE,YAAY,CAAC,MAAM,CAAC,SAAS;QACxC,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,WAAW,EAAE,OAAO,CAAC,WAAW;QAChC,YAAY,EAAE,OAAO,CAAC,YAAY;QAClC,aAAa,EAAE,OAAO,CAAC,aAAa;QACpC,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,YAAY,EAAE,OAAO,CAAC,YAAY;QAClC,UAAU,EAAE,OAAO,CAAC,UAAU;KAC9B,CAAC,CAAC;IACH,IAAI,CAAC;QACJ,OAAO,MAAM,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACxC,CAAC;IAAC,OAAO,KAAc,EAAE,CAAC;QACzB,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,6BAA6B,CAAC;QACvF,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,OAAO,IAAI,CAAC,CAAC;QACjD,OAAO,CAAC,CAAC;IACV,CAAC;YAAS,CAAC;QACV,KAAK,MAAM,SAAS,IAAI,cAAc,CAAC,IAAI,EAAE,EAAE,CAAC;YAC/C,YAAY,CAAC,MAAM,CAAC,KAAK,CACxB,sBAAsB,SAAS,CAAC,IAAI,MAAM,SAAS,CAAC,OAAO,QAAM,SAAS,CAAC,MAAM,IAAI,CACrF,CAAC;QACH,CAAC;QACD,MAAM,UAAU,CAAC,KAAK,EAAE,CAAC;IAC1B,CAAC;AAAA,CACD","sourcesContent":["import type { HostToolBinary } from \"../../utils/tools-manager.ts\";\nimport { buildSandboxedCliLaunch, type HostSkillPaths } from \"./cli-launch.ts\";\nimport { createLinuxSandboxBackend } from \"./linux-backend.ts\";\nimport { createMacosSandboxBackend } from \"./macos-backend.ts\";\nimport { createSandboxPolicy } from \"./policy.ts\";\nimport { createSandboxSupervisor, type SandboxBackend } from \"./supervisor.ts\";\nimport { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface CliSandboxDependencies {\n\tcreateBackend: (options: { violationStore: SandboxViolationStore }) => SandboxBackend;\n\tstderr: { write(message: string): boolean };\n}\n\n/** Routes to the platform adapter for the running OS; each adapter self-reports\n * `unavailable` on any other platform, so this only needs to pick the one whose\n * enforcement is actually reachable here. */\nfunction createDefaultSandboxBackend(options: { violationStore: SandboxViolationStore }): SandboxBackend {\n\tif (process.platform === \"darwin\") return createMacosSandboxBackend(options);\n\treturn createLinuxSandboxBackend(options);\n}\n\nconst defaultDependencies: CliSandboxDependencies = {\n\tcreateBackend: createDefaultSandboxBackend,\n\tstderr: process.stderr,\n};\n\n/**\n * Start a normal CLI runtime beneath the whole-process boundary. The caller returns\n * the result directly so the outer process never continues into `main()` afterward.\n */\nexport async function launchSandboxedCli(options: {\n\tcommand: string;\n\targs: readonly string[];\n\tenvironment: NodeJS.ProcessEnv;\n\tworkspace: string;\n\tallowedHosts?: readonly string[];\n\treadOnlyPaths?: readonly string[];\n\tauthPath?: string;\n\ttoolBinaries?: readonly HostToolBinary[];\n\tskillPaths?: HostSkillPaths;\n\tdependencies?: Partial<CliSandboxDependencies>;\n}): Promise<number> {\n\tconst dependencies = { ...defaultDependencies, ...options.dependencies };\n\tconst policyResult = createSandboxPolicy({ workspace: options.workspace, allowedHosts: options.allowedHosts });\n\tif (policyResult.kind === \"invalid\") {\n\t\tdependencies.stderr.write(`Error: OS sandbox is not enforcing this agent session: ${policyResult.reason}\\n`);\n\t\treturn 1;\n\t}\n\tconst violationStore = new SandboxViolationStore();\n\tconst backend = dependencies.createBackend({ violationStore });\n\tconst supervisor = createSandboxSupervisor({ backend, policy: policyResult.policy });\n\tconst launch = buildSandboxedCliLaunch({\n\t\tworkspace: policyResult.policy.workspace,\n\t\tcommand: options.command,\n\t\targs: options.args,\n\t\tenvironment: options.environment,\n\t\tallowedHosts: options.allowedHosts,\n\t\treadOnlyPaths: options.readOnlyPaths,\n\t\tauthPath: options.authPath,\n\t\ttoolBinaries: options.toolBinaries,\n\t\tskillPaths: options.skillPaths,\n\t});\n\ttry {\n\t\treturn await supervisor.launch(launch);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : \"Failed to start OS sandbox.\";\n\t\tdependencies.stderr.write(`Error: ${message}\\n`);\n\t\treturn 1;\n\t} finally {\n\t\tfor (const violation of violationStore.list()) {\n\t\t\tdependencies.stderr.write(\n\t\t\t\t`Sandbox violation (${violation.kind}): ${violation.command} — ${violation.detail}\\n`,\n\t\t\t);\n\t\t}\n\t\tawait supervisor.close();\n\t}\n}\n"]}
1
+ {"version":3,"file":"cli-supervisor.js","sourceRoot":"","sources":["../../../src/core/sandbox/cli-supervisor.ts"],"names":[],"mappings":"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","sourcesContent":["import { parseArgs } from \"../../cli/args.ts\";\nimport { reportConcurrentSessionRefusal } from \"../../cli/concurrent-session.ts\";\nimport type { HostToolBinary } from \"../../utils/tools-manager.ts\";\nimport { acquireSessionLease, readLiveSessionLeases } from \"../session-lease.ts\";\nimport { buildSandboxedCliLaunch, getSandboxSessionDirectory, type HostSkillPaths } from \"./cli-launch.ts\";\nimport { createLinuxSandboxBackend } from \"./linux-backend.ts\";\nimport { createMacosSandboxBackend } from \"./macos-backend.ts\";\nimport { createSandboxPolicy } from \"./policy.ts\";\nimport { createCredentialProxy, resolveCredentialChannelPaths } from \"./rpc/credential-proxy.ts\";\nimport { createSandboxSupervisor, type SandboxBackend, type SandboxLaunch } from \"./supervisor.ts\";\nimport { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface CliSandboxDependencies {\n\tcreateBackend: (options: { violationStore: SandboxViolationStore }) => SandboxBackend;\n\tstderr: { write(message: string): boolean };\n}\n\n/** Routes to the platform adapter for the running OS; each adapter self-reports\n * `unavailable` on any other platform, so this only needs to pick the one whose\n * enforcement is actually reachable here. */\nfunction createDefaultSandboxBackend(options: { violationStore: SandboxViolationStore }): SandboxBackend {\n\tif (process.platform === \"darwin\") return createMacosSandboxBackend(options);\n\treturn createLinuxSandboxBackend(options);\n}\n\nconst defaultDependencies: CliSandboxDependencies = {\n\tcreateBackend: createDefaultSandboxBackend,\n\tstderr: process.stderr,\n};\n\n/**\n * Start a normal CLI runtime beneath the whole-process boundary. The caller returns\n * the result directly so the outer process never continues into `main()` afterward.\n */\nexport async function launchSandboxedCli(options: {\n\tcommand: string;\n\targs: readonly string[];\n\tenvironment: NodeJS.ProcessEnv;\n\tworkspace: string;\n\tallowedHosts?: readonly string[];\n\treadOnlyPaths?: readonly string[];\n\tauthPath?: string;\n\ttoolBinaries?: readonly HostToolBinary[];\n\tskillPaths?: HostSkillPaths;\n\tdependencies?: Partial<CliSandboxDependencies>;\n}): Promise<number> {\n\tconst dependencies = { ...defaultDependencies, ...options.dependencies };\n\tconst policyResult = createSandboxPolicy({ workspace: options.workspace, allowedHosts: options.allowedHosts });\n\tif (policyResult.kind === \"invalid\") {\n\t\tdependencies.stderr.write(`Error: OS sandbox is not enforcing this agent session: ${policyResult.reason}\\n`);\n\t\treturn 1;\n\t}\n\tconst violationStore = new SandboxViolationStore();\n\tconst backend = dependencies.createBackend({ violationStore });\n\n\tconst supervisor = createSandboxSupervisor({ backend, policy: policyResult.policy });\n\tlet credentialProxy: Awaited<ReturnType<typeof createCredentialProxy>> | undefined;\n\tlet lease: ReturnType<typeof acquireSessionLease> | undefined;\n\ttry {\n\t\tconst parsed = parseArgs(options.args);\n\t\tconst wantsPersistentSession = !parsed.noSession && !parsed.help && parsed.listModels === undefined;\n\t\tconst sessionDirectory = getSandboxSessionDirectory(policyResult.policy.workspace);\n\t\tif (wantsPersistentSession && !parsed.allowConcurrent) {\n\t\t\tconst liveSessions = readLiveSessionLeases(sessionDirectory, policyResult.policy.workspace);\n\t\t\tif (liveSessions.length > 0) {\n\t\t\t\treportConcurrentSessionRefusal(liveSessions, policyResult.policy.workspace);\n\t\t\t\treturn 1;\n\t\t\t}\n\t\t}\n\t\tlease = wantsPersistentSession\n\t\t\t? acquireSessionLease(sessionDirectory, policyResult.policy.workspace, `supervisor-${process.pid}`)\n\t\t\t: undefined;\n\n\t\t// Open the host-owned credential channel only after startup checks that can\n\t\t// return early. AuthStorage creates a missing canonical file as 0600, so a\n\t\t// first-run credential writes get the same channel and read-only projection as later runs.\n\t\tlet credentialChannel: SandboxLaunch[\"credentialChannel\"];\n\t\tif (options.authPath && backend.status.kind === \"enforced\") {\n\t\t\tconst paths = resolveCredentialChannelPaths();\n\t\t\tcredentialProxy = await createCredentialProxy({\n\t\t\t\tauthPath: options.authPath,\n\t\t\t\tviolationStore,\n\t\t\t\tsocketPath: paths.hostSocketPath,\n\t\t\t\tcleanupDirectory: paths.hostSocketDirectory,\n\t\t\t});\n\t\t\tcredentialChannel = paths;\n\t\t}\n\n\t\tconst launch = buildSandboxedCliLaunch({\n\t\t\tworkspace: policyResult.policy.workspace,\n\t\t\tcommand: options.command,\n\t\t\targs: options.args,\n\t\t\tenvironment: options.environment,\n\t\t\tallowedHosts: options.allowedHosts,\n\t\t\treadOnlyPaths: options.readOnlyPaths,\n\t\t\tauthPath: options.authPath,\n\t\t\ttoolBinaries: options.toolBinaries,\n\t\t\tskillPaths: options.skillPaths,\n\t\t\tcredentialChannel,\n\t\t});\n\t\treturn await supervisor.launch(launch);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : \"Failed to start OS sandbox.\";\n\t\tdependencies.stderr.write(`Error: ${message}\\n`);\n\t\treturn 1;\n\t} finally {\n\t\tlease?.release();\n\t\tconst cleanupResults = await Promise.allSettled([supervisor.close(), credentialProxy?.close()]);\n\t\tfor (const violation of violationStore.list()) {\n\t\t\tdependencies.stderr.write(\n\t\t\t\t`Sandbox violation (${violation.kind}): ${violation.command} — ${violation.detail}\\n`,\n\t\t\t);\n\t\t}\n\t\tfor (const result of cleanupResults) {\n\t\t\tif (result.status === \"rejected\") {\n\t\t\t\tdependencies.stderr.write(\"Warning: sandbox cleanup did not complete cleanly.\\n\");\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"linux-backend.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/linux-backend.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAa,MAAM,oBAAoB,CAAC;AAMtD,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,iBAAiB,CAAC;AACrE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAK7D,MAAM,WAAW,0BAA0B;IAC1C,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC,2FAA2F;IAC3F,UAAU,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AA6BD;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,kBAAkB,GAAE,MAAiB,GAAG;IAC/E,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;CACxB,CAKA;AAmBD;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,0BAA0B,GAAG,cAAc,CA8K9F","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { randomBytes } from \"node:crypto\";\nimport { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\n/** AF_UNIX `sun_path` is 108 bytes on Linux, including the terminating NUL. */\nconst SUN_PATH_LIMIT = 108;\n\nexport interface LinuxSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/** Injectable only to prove crash cleanup in tests; production spawns `bwrap` directly. */\n\tspawnChild?: typeof spawn;\n}\n\nfunction commandExists(command: string): boolean {\n\tconst result = spawnSync(command, [\"--version\"], { stdio: \"ignore\", timeout: 1_000 });\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\nfunction readOnlyMountArguments(path: string, kind: \"directory\" | \"file\" = \"directory\", descriptor = 3): string[] {\n\tconst target = resolve(path);\n\tconst directory = dirname(target);\n\tconst ancestors: string[] = [];\n\tlet current = directory;\n\twhile (current !== \"/\" && current !== \"/home\") {\n\t\tancestors.push(current);\n\t\tcurrent = dirname(current);\n\t}\n\tconst parentArguments = ancestors.reverse().flatMap((ancestor) => [\"--dir\", ancestor]);\n\treturn kind === \"file\"\n\t\t? [...parentArguments, \"--tmpfs\", directory, \"--perms\", \"0400\", \"--file\", String(descriptor), target]\n\t\t: [...parentArguments, \"--ro-bind\", directory, directory];\n}\n\n/**\n * Where the supervisor's proxy socket lives on each side of the boundary.\n *\n * AF_UNIX caps `sun_path` at 108 bytes, so the socket cannot live under the workspace:\n * a deep-but-ordinary project directory exhausts that budget and the sandbox fails to\n * start at all with `listen EINVAL`. The host side therefore gets a short, unique path\n * under the system temp directory, and it is bind-mounted into the child.\n *\n * The child-side path has to sit under `/home`. That is the one writable mount at the\n * point the mountpoint is created — the sandbox root is a read-only bind, so bwrap\n * cannot create a mountpoint at `/run`, `/tmp`, or anywhere else on it.\n */\nexport function resolveProxySocketPaths(temporaryDirectory: string = tmpdir()): {\n\thostSocketPath: string;\n\tchildSocketPath: string;\n} {\n\tconst name = `apex-${process.pid}-${randomBytes(4).toString(\"hex\")}.sock`;\n\t// An unusually long TMPDIR would reintroduce the very limit this avoids.\n\tconst base = join(temporaryDirectory, name).length + 1 > SUN_PATH_LIMIT ? \"/tmp\" : temporaryDirectory;\n\treturn { hostSocketPath: join(base, name), childSocketPath: `/home/${name}` };\n}\n\n/**\n * Classify an unsuccessful child from what its stderr actually evidences, or return\n * undefined when nothing suggests the boundary refused anything.\n *\n * A non-zero exit is not a violation. An invalid API key, a failing test command, or a\n * script exiting 1 are the child's own business, and recording them as sandbox\n * violations blames the boundary for failures it had no part in — which is worse than\n * silence, because it sends whoever reads the output looking for a refusal that never\n * happened. Only Linux's own refusal wording earns a violation here; the proxy records\n * network refusals itself, before this fallback is consulted.\n */\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | undefined {\n\tif (/Read-only file system|Permission denied|Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (/Network is unreachable|ENETUNREACH|EHOSTUNREACH/i.test(stderr)) return \"network\";\n\treturn undefined;\n}\n\n/**\n * Linux's deliberately small platform adapter. The sandbox starts from a read-only\n * host root and bind-mounts only the canonical workspace as writable. `--unshare-net`\n * removes all direct network interfaces for the child and every descendant.\n */\nexport function createLinuxSandboxBackend(options?: LinuxSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"linux\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on Linux only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? commandExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"bwrap\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"Bubblewrap (bwrap) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\tconst stateDirectory = join(launch.policy.workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tconst { hostSocketPath, childSocketPath } = resolveProxySocketPaths();\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\tsocketPath: hostSocketPath,\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\n\t\t\t// .cjs forces CommonJS regardless of the target workspace's package.json \"type\"\n\t\t\t// field -- a plain .js here would be parsed as ESM under \"type\": \"module\" and\n\t\t\t// crash on `require`, since Node resolves module type from the nearest package.json.\n\t\t\tconst relayScriptPath = join(stateDirectory, \"relay.cjs\");\n\t\t\twriteFileSync(\n\t\t\t\trelayScriptPath,\n\t\t\t\t`\nconst net = require(\"node:net\");\nconst child_process = require(\"node:child_process\");\n\nconst socketPath = process.env.APEX_UDS_PATH;\nconst server = net.createServer((c) => {\n\tconst client = net.connect(socketPath);\n\tc.pipe(client).pipe(c);\n\tc.on(\"error\", () => {});\n\tclient.on(\"error\", () => {});\n});\n\nserver.listen(0, \"127.0.0.1\", () => {\n\tconst port = server.address().port;\n\tconst env = Object.assign({}, process.env, {\n\t\tHTTP_PROXY: \\`http://127.0.0.1:\\${port}\\`,\n\t\tHTTPS_PROXY: \\`http://127.0.0.1:\\${port}\\`\n\t});\n\tconst args = process.argv.slice(2);\n\tconst child = child_process.spawn(args[0], args.slice(1), {\n\t\tstdio: \"inherit\",\n\t\tenv: env,\n\t});\n\tchild.on(\"exit\", (code, signal) => {\n\t\tprocess.exit(code ?? (signal ? 128 : 1));\n\t});\n});\nserver.on(\"error\", (err) => {\n\tconsole.error(\"Relay error:\", err);\n\tprocess.exit(1);\n});\n`.trim(),\n\t\t\t);\n\n\t\t\tconst readOnlyMounts = [process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])].flatMap((path) =>\n\t\t\t\treadOnlyMountArguments(path),\n\t\t\t);\n\t\t\tconst readOnlyFileMounts = (launch.readOnlyFiles ?? []).flatMap((path, index) =>\n\t\t\t\treadOnlyMountArguments(path, \"file\", index + 3),\n\t\t\t);\n\t\t\tconst readOnlyFileDescriptors = (launch.readOnlyFiles ?? []).map((path) => openSync(path, \"r\"));\n\t\t\t// The descriptors above are opened before the child spawns and must be closed\n\t\t\t// on every exit path -- including a spawn/wait rejection -- or a crashed launch\n\t\t\t// leaks an open handle onto a host-owned credential file for the process's life.\n\t\t\ttry {\n\t\t\t\tconst spawnBwrap = options?.spawnChild ?? spawn;\n\t\t\t\tconst child = spawnBwrap(\n\t\t\t\t\t\"bwrap\",\n\t\t\t\t\t[\n\t\t\t\t\t\t\"--new-session\",\n\t\t\t\t\t\t\"--die-with-parent\",\n\t\t\t\t\t\t\"--unshare-user\",\n\t\t\t\t\t\t\"--unshare-pid\",\n\t\t\t\t\t\t\"--unshare-net\",\n\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"--tmpfs\",\n\t\t\t\t\t\t\"/home\",\n\t\t\t\t\t\t// Immediately after the /home tmpfs: that is the only writable mount at\n\t\t\t\t\t\t// this point, so it is the only place bwrap can create the mountpoint.\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\thostSocketPath,\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t...readOnlyMounts,\n\t\t\t\t\t\t...readOnlyFileMounts,\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t// After the workspace bind: these destinations sit inside it, and an\n\t\t\t\t\t\t// earlier mount would be masked when the workspace is bound over them.\n\t\t\t\t\t\t...(launch.readOnlyBinaries ?? []).flatMap(({ source, destination }) => [\n\t\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\tsource,\n\t\t\t\t\t\t\tdestination,\n\t\t\t\t\t\t]),\n\t\t\t\t\t\t\"--dev\",\n\t\t\t\t\t\t\"/dev\",\n\t\t\t\t\t\t\"--proc\",\n\t\t\t\t\t\t\"/proc\",\n\t\t\t\t\t\t\"--chdir\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"HOME\",\n\t\t\t\t\t\tlaunch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"TMPDIR\",\n\t\t\t\t\t\tlaunch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"APEX_UDS_PATH\",\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t\"--\",\n\t\t\t\t\t\tprocess.execPath,\n\t\t\t\t\t\trelayScriptPath,\n\t\t\t\t\t\tlaunch.command,\n\t\t\t\t\t\t...launch.args,\n\t\t\t\t\t],\n\t\t\t\t\t{ env: launch.environment, stdio: [\"inherit\", \"inherit\", \"pipe\", ...readOnlyFileDescriptors] },\n\t\t\t\t);\n\t\t\t\tlet stderr = \"\";\n\t\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\t\tstderr += chunk;\n\t\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t\t});\n\t\t\t\tconst exitCode = await waitForExit(child);\n\t\t\t\tawait proxy?.close();\n\t\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\t\tif (kind) {\n\t\t\t\t\tviolationStore?.add({\n\t\t\t\t\t\tkind,\n\t\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\treturn exitCode;\n\t\t\t} finally {\n\t\t\t\tfor (const descriptor of readOnlyFileDescriptors) closeSync(descriptor);\n\t\t\t\t// The socket now lives outside the workspace, so nothing else will ever clean\n\t\t\t\t// it up; leaving it would litter the temp directory once per launch.\n\t\t\t\trmSync(hostSocketPath, { force: true });\n\t\t\t}\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
1
+ {"version":3,"file":"linux-backend.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/linux-backend.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAa,MAAM,oBAAoB,CAAC;AAMtD,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,iBAAiB,CAAC;AAErE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAK7D,MAAM,WAAW,0BAA0B;IAC1C,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC,2FAA2F;IAC3F,UAAU,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AA6BD;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,kBAAkB,GAAE,MAAiB,GAAG;IAC/E,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;CACxB,CAKA;AAmBD;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,0BAA0B,GAAG,cAAc,CA4L9F","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { randomBytes } from \"node:crypto\";\nimport { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport { publishTerminalSize, TERMINAL_SIZE_PATH_VARIABLE } from \"./terminal-size.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\n/** AF_UNIX `sun_path` is 108 bytes on Linux, including the terminating NUL. */\nconst SUN_PATH_LIMIT = 108;\n\nexport interface LinuxSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/** Injectable only to prove crash cleanup in tests; production spawns `bwrap` directly. */\n\tspawnChild?: typeof spawn;\n}\n\nfunction commandExists(command: string): boolean {\n\tconst result = spawnSync(command, [\"--version\"], { stdio: \"ignore\", timeout: 1_000 });\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\nfunction readOnlyMountArguments(path: string, kind: \"directory\" | \"file\" = \"directory\", descriptor = 3): string[] {\n\tconst target = resolve(path);\n\tconst directory = dirname(target);\n\tconst ancestors: string[] = [];\n\tlet current = directory;\n\twhile (current !== \"/\" && current !== \"/home\") {\n\t\tancestors.push(current);\n\t\tcurrent = dirname(current);\n\t}\n\tconst parentArguments = ancestors.reverse().flatMap((ancestor) => [\"--dir\", ancestor]);\n\treturn kind === \"file\"\n\t\t? [...parentArguments, \"--tmpfs\", directory, \"--perms\", \"0400\", \"--file\", String(descriptor), target]\n\t\t: [...parentArguments, \"--ro-bind\", directory, directory];\n}\n\n/**\n * Where the supervisor's proxy socket lives on each side of the boundary.\n *\n * AF_UNIX caps `sun_path` at 108 bytes, so the socket cannot live under the workspace:\n * a deep-but-ordinary project directory exhausts that budget and the sandbox fails to\n * start at all with `listen EINVAL`. The host side therefore gets a short, unique path\n * under the system temp directory, and it is bind-mounted into the child.\n *\n * The child-side path has to sit under `/home`. That is the one writable mount at the\n * point the mountpoint is created — the sandbox root is a read-only bind, so bwrap\n * cannot create a mountpoint at `/run`, `/tmp`, or anywhere else on it.\n */\nexport function resolveProxySocketPaths(temporaryDirectory: string = tmpdir()): {\n\thostSocketPath: string;\n\tchildSocketPath: string;\n} {\n\tconst name = `apex-${process.pid}-${randomBytes(4).toString(\"hex\")}.sock`;\n\t// An unusually long TMPDIR would reintroduce the very limit this avoids.\n\tconst base = join(temporaryDirectory, name).length + 1 > SUN_PATH_LIMIT ? \"/tmp\" : temporaryDirectory;\n\treturn { hostSocketPath: join(base, name), childSocketPath: `/home/${name}` };\n}\n\n/**\n * Classify an unsuccessful child from what its stderr actually evidences, or return\n * undefined when nothing suggests the boundary refused anything.\n *\n * A non-zero exit is not a violation. An invalid API key, a failing test command, or a\n * script exiting 1 are the child's own business, and recording them as sandbox\n * violations blames the boundary for failures it had no part in — which is worse than\n * silence, because it sends whoever reads the output looking for a refusal that never\n * happened. Only Linux's own refusal wording earns a violation here; the proxy records\n * network refusals itself, before this fallback is consulted.\n */\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | undefined {\n\tif (/Read-only file system|Permission denied|Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (/Network is unreachable|ENETUNREACH|EHOSTUNREACH/i.test(stderr)) return \"network\";\n\treturn undefined;\n}\n\n/**\n * Linux's deliberately small platform adapter. The sandbox starts from a read-only\n * host root and bind-mounts only the canonical workspace as writable. `--unshare-net`\n * removes all direct network interfaces for the child and every descendant.\n */\nexport function createLinuxSandboxBackend(options?: LinuxSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"linux\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on Linux only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? commandExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"bwrap\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"Bubblewrap (bwrap) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\tconst stateDirectory = join(launch.policy.workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tconst { hostSocketPath, childSocketPath } = resolveProxySocketPaths();\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\tsocketPath: hostSocketPath,\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\n\t\t\t// .cjs forces CommonJS regardless of the target workspace's package.json \"type\"\n\t\t\t// field -- a plain .js here would be parsed as ESM under \"type\": \"module\" and\n\t\t\t// crash on `require`, since Node resolves module type from the nearest package.json.\n\t\t\tconst relayScriptPath = join(stateDirectory, \"relay.cjs\");\n\t\t\twriteFileSync(\n\t\t\t\trelayScriptPath,\n\t\t\t\t`\nconst net = require(\"node:net\");\nconst child_process = require(\"node:child_process\");\n\nconst socketPath = process.env.APEX_UDS_PATH;\nconst server = net.createServer((c) => {\n\tconst client = net.connect(socketPath);\n\tc.pipe(client).pipe(c);\n\tc.on(\"error\", () => {});\n\tclient.on(\"error\", () => {});\n});\n\nserver.listen(0, \"127.0.0.1\", () => {\n\tconst port = server.address().port;\n\tconst env = Object.assign({}, process.env, {\n\t\tHTTP_PROXY: \\`http://127.0.0.1:\\${port}\\`,\n\t\tHTTPS_PROXY: \\`http://127.0.0.1:\\${port}\\`\n\t});\n\tconst args = process.argv.slice(2);\n\tconst child = child_process.spawn(args[0], args.slice(1), {\n\t\tstdio: \"inherit\",\n\t\tenv: env,\n\t});\n\tchild.on(\"exit\", (code, signal) => {\n\t\tprocess.exit(code ?? (signal ? 128 : 1));\n\t});\n});\nserver.on(\"error\", (err) => {\n\tconsole.error(\"Relay error:\", err);\n\tprocess.exit(1);\n});\n`.trim(),\n\t\t\t);\n\n\t\t\tconst readOnlyMounts = [process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])].flatMap((path) =>\n\t\t\t\treadOnlyMountArguments(path),\n\t\t\t);\n\t\t\tconst readOnlyFileMounts = (launch.readOnlyFiles ?? []).flatMap((path, index) =>\n\t\t\t\treadOnlyMountArguments(path, \"file\", index + 3),\n\t\t\t);\n\t\t\tconst readOnlyFileDescriptors = (launch.readOnlyFiles ?? []).map((path) => openSync(path, \"r\"));\n\t\t\t// The descriptors above are opened before the child spawns and must be closed\n\t\t\t// on every exit path -- including a spawn/wait rejection -- or a crashed launch\n\t\t\t// leaks an open handle onto a host-owned credential file for the process's life.\n\t\t\t// The workspace is bind-mounted read-write into the sandbox, so a file\n\t\t\t// here is visible at the same absolute path on both sides of it.\n\t\t\tconst terminalSizePath = join(stateDirectory, \"terminal-size\");\n\t\t\tconst stopPublishingTerminalSize = publishTerminalSize(terminalSizePath);\n\t\t\ttry {\n\t\t\t\tconst spawnBwrap = options?.spawnChild ?? spawn;\n\t\t\t\tconst child = spawnBwrap(\n\t\t\t\t\t\"bwrap\",\n\t\t\t\t\t[\n\t\t\t\t\t\t\"--new-session\",\n\t\t\t\t\t\t\"--die-with-parent\",\n\t\t\t\t\t\t\"--unshare-user\",\n\t\t\t\t\t\t\"--unshare-pid\",\n\t\t\t\t\t\t\"--unshare-net\",\n\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"--tmpfs\",\n\t\t\t\t\t\t\"/home\",\n\t\t\t\t\t\t// Immediately after the /home tmpfs: that is the only writable mount at\n\t\t\t\t\t\t// this point, so it is the only place bwrap can create the mountpoint.\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\thostSocketPath,\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t// The credential channel socket: same writable-under-/home constraint\n\t\t\t\t\t\t// and same position as the network socket above. Its child-side path\n\t\t\t\t\t\t// is what `APEX_CREDENTIAL_PROXY_PATH` names in the launch environment.\n\t\t\t\t\t\t...(launch.credentialChannel\n\t\t\t\t\t\t\t? [\"--bind\", launch.credentialChannel.hostSocketPath, launch.credentialChannel.childSocketPath]\n\t\t\t\t\t\t\t: []),\n\t\t\t\t\t\t...readOnlyMounts,\n\t\t\t\t\t\t...readOnlyFileMounts,\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t// After the workspace bind: these destinations sit inside it, and an\n\t\t\t\t\t\t// earlier mount would be masked when the workspace is bound over them.\n\t\t\t\t\t\t...(launch.readOnlyBinaries ?? []).flatMap(({ source, destination }) => [\n\t\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\tsource,\n\t\t\t\t\t\t\tdestination,\n\t\t\t\t\t\t]),\n\t\t\t\t\t\t\"--dev\",\n\t\t\t\t\t\t\"/dev\",\n\t\t\t\t\t\t\"--proc\",\n\t\t\t\t\t\t\"/proc\",\n\t\t\t\t\t\t\"--chdir\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"HOME\",\n\t\t\t\t\t\tlaunch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"TMPDIR\",\n\t\t\t\t\t\tlaunch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"APEX_UDS_PATH\",\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\tTERMINAL_SIZE_PATH_VARIABLE,\n\t\t\t\t\t\tterminalSizePath,\n\t\t\t\t\t\t\"--\",\n\t\t\t\t\t\tprocess.execPath,\n\t\t\t\t\t\trelayScriptPath,\n\t\t\t\t\t\tlaunch.command,\n\t\t\t\t\t\t...launch.args,\n\t\t\t\t\t],\n\t\t\t\t\t{ env: launch.environment, stdio: [\"inherit\", \"inherit\", \"pipe\", ...readOnlyFileDescriptors] },\n\t\t\t\t);\n\t\t\t\tlet stderr = \"\";\n\t\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\t\tstderr += chunk;\n\t\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t\t});\n\t\t\t\tconst exitCode = await waitForExit(child);\n\t\t\t\tawait proxy?.close();\n\t\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\t\tif (kind) {\n\t\t\t\t\tviolationStore?.add({\n\t\t\t\t\t\tkind,\n\t\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\treturn exitCode;\n\t\t\t} finally {\n\t\t\t\tstopPublishingTerminalSize();\n\t\t\t\tfor (const descriptor of readOnlyFileDescriptors) closeSync(descriptor);\n\t\t\t\t// The socket now lives outside the workspace, so nothing else will ever clean\n\t\t\t\t// it up; leaving it would litter the temp directory once per launch.\n\t\t\t\trmSync(hostSocketPath, { force: true });\n\t\t\t}\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
@@ -4,6 +4,7 @@ import { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { createSandboxNetworkProxy } from "./network-proxy.js";
7
+ import { publishTerminalSize, TERMINAL_SIZE_PATH_VARIABLE } from "./terminal-size.js";
7
8
  /** AF_UNIX `sun_path` is 108 bytes on Linux, including the terminating NUL. */
8
9
  const SUN_PATH_LIMIT = 108;
9
10
  function commandExists(command) {
@@ -148,6 +149,10 @@ server.on("error", (err) => {
148
149
  // The descriptors above are opened before the child spawns and must be closed
149
150
  // on every exit path -- including a spawn/wait rejection -- or a crashed launch
150
151
  // leaks an open handle onto a host-owned credential file for the process's life.
152
+ // The workspace is bind-mounted read-write into the sandbox, so a file
153
+ // here is visible at the same absolute path on both sides of it.
154
+ const terminalSizePath = join(stateDirectory, "terminal-size");
155
+ const stopPublishingTerminalSize = publishTerminalSize(terminalSizePath);
151
156
  try {
152
157
  const spawnBwrap = options?.spawnChild ?? spawn;
153
158
  const child = spawnBwrap("bwrap", [
@@ -166,6 +171,12 @@ server.on("error", (err) => {
166
171
  "--bind",
167
172
  hostSocketPath,
168
173
  childSocketPath,
174
+ // The credential channel socket: same writable-under-/home constraint
175
+ // and same position as the network socket above. Its child-side path
176
+ // is what `APEX_CREDENTIAL_PROXY_PATH` names in the launch environment.
177
+ ...(launch.credentialChannel
178
+ ? ["--bind", launch.credentialChannel.hostSocketPath, launch.credentialChannel.childSocketPath]
179
+ : []),
169
180
  ...readOnlyMounts,
170
181
  ...readOnlyFileMounts,
171
182
  "--bind",
@@ -193,6 +204,9 @@ server.on("error", (err) => {
193
204
  "--setenv",
194
205
  "APEX_UDS_PATH",
195
206
  childSocketPath,
207
+ "--setenv",
208
+ TERMINAL_SIZE_PATH_VARIABLE,
209
+ terminalSizePath,
196
210
  "--",
197
211
  process.execPath,
198
212
  relayScriptPath,
@@ -220,6 +234,7 @@ server.on("error", (err) => {
220
234
  return exitCode;
221
235
  }
222
236
  finally {
237
+ stopPublishingTerminalSize();
223
238
  for (const descriptor of readOnlyFileDescriptors)
224
239
  closeSync(descriptor);
225
240
  // The socket now lives outside the workspace, so nothing else will ever clean
@@ -1 +1 @@
1
- {"version":3,"file":"linux-backend.js","sourceRoot":"","sources":["../../../src/core/sandbox/linux-backend.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { randomBytes } from \"node:crypto\";\nimport { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\n/** AF_UNIX `sun_path` is 108 bytes on Linux, including the terminating NUL. */\nconst SUN_PATH_LIMIT = 108;\n\nexport interface LinuxSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/** Injectable only to prove crash cleanup in tests; production spawns `bwrap` directly. */\n\tspawnChild?: typeof spawn;\n}\n\nfunction commandExists(command: string): boolean {\n\tconst result = spawnSync(command, [\"--version\"], { stdio: \"ignore\", timeout: 1_000 });\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\nfunction readOnlyMountArguments(path: string, kind: \"directory\" | \"file\" = \"directory\", descriptor = 3): string[] {\n\tconst target = resolve(path);\n\tconst directory = dirname(target);\n\tconst ancestors: string[] = [];\n\tlet current = directory;\n\twhile (current !== \"/\" && current !== \"/home\") {\n\t\tancestors.push(current);\n\t\tcurrent = dirname(current);\n\t}\n\tconst parentArguments = ancestors.reverse().flatMap((ancestor) => [\"--dir\", ancestor]);\n\treturn kind === \"file\"\n\t\t? [...parentArguments, \"--tmpfs\", directory, \"--perms\", \"0400\", \"--file\", String(descriptor), target]\n\t\t: [...parentArguments, \"--ro-bind\", directory, directory];\n}\n\n/**\n * Where the supervisor's proxy socket lives on each side of the boundary.\n *\n * AF_UNIX caps `sun_path` at 108 bytes, so the socket cannot live under the workspace:\n * a deep-but-ordinary project directory exhausts that budget and the sandbox fails to\n * start at all with `listen EINVAL`. The host side therefore gets a short, unique path\n * under the system temp directory, and it is bind-mounted into the child.\n *\n * The child-side path has to sit under `/home`. That is the one writable mount at the\n * point the mountpoint is created — the sandbox root is a read-only bind, so bwrap\n * cannot create a mountpoint at `/run`, `/tmp`, or anywhere else on it.\n */\nexport function resolveProxySocketPaths(temporaryDirectory: string = tmpdir()): {\n\thostSocketPath: string;\n\tchildSocketPath: string;\n} {\n\tconst name = `apex-${process.pid}-${randomBytes(4).toString(\"hex\")}.sock`;\n\t// An unusually long TMPDIR would reintroduce the very limit this avoids.\n\tconst base = join(temporaryDirectory, name).length + 1 > SUN_PATH_LIMIT ? \"/tmp\" : temporaryDirectory;\n\treturn { hostSocketPath: join(base, name), childSocketPath: `/home/${name}` };\n}\n\n/**\n * Classify an unsuccessful child from what its stderr actually evidences, or return\n * undefined when nothing suggests the boundary refused anything.\n *\n * A non-zero exit is not a violation. An invalid API key, a failing test command, or a\n * script exiting 1 are the child's own business, and recording them as sandbox\n * violations blames the boundary for failures it had no part in — which is worse than\n * silence, because it sends whoever reads the output looking for a refusal that never\n * happened. Only Linux's own refusal wording earns a violation here; the proxy records\n * network refusals itself, before this fallback is consulted.\n */\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | undefined {\n\tif (/Read-only file system|Permission denied|Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (/Network is unreachable|ENETUNREACH|EHOSTUNREACH/i.test(stderr)) return \"network\";\n\treturn undefined;\n}\n\n/**\n * Linux's deliberately small platform adapter. The sandbox starts from a read-only\n * host root and bind-mounts only the canonical workspace as writable. `--unshare-net`\n * removes all direct network interfaces for the child and every descendant.\n */\nexport function createLinuxSandboxBackend(options?: LinuxSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"linux\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on Linux only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? commandExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"bwrap\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"Bubblewrap (bwrap) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\tconst stateDirectory = join(launch.policy.workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tconst { hostSocketPath, childSocketPath } = resolveProxySocketPaths();\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\tsocketPath: hostSocketPath,\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\n\t\t\t// .cjs forces CommonJS regardless of the target workspace's package.json \"type\"\n\t\t\t// field -- a plain .js here would be parsed as ESM under \"type\": \"module\" and\n\t\t\t// crash on `require`, since Node resolves module type from the nearest package.json.\n\t\t\tconst relayScriptPath = join(stateDirectory, \"relay.cjs\");\n\t\t\twriteFileSync(\n\t\t\t\trelayScriptPath,\n\t\t\t\t`\nconst net = require(\"node:net\");\nconst child_process = require(\"node:child_process\");\n\nconst socketPath = process.env.APEX_UDS_PATH;\nconst server = net.createServer((c) => {\n\tconst client = net.connect(socketPath);\n\tc.pipe(client).pipe(c);\n\tc.on(\"error\", () => {});\n\tclient.on(\"error\", () => {});\n});\n\nserver.listen(0, \"127.0.0.1\", () => {\n\tconst port = server.address().port;\n\tconst env = Object.assign({}, process.env, {\n\t\tHTTP_PROXY: \\`http://127.0.0.1:\\${port}\\`,\n\t\tHTTPS_PROXY: \\`http://127.0.0.1:\\${port}\\`\n\t});\n\tconst args = process.argv.slice(2);\n\tconst child = child_process.spawn(args[0], args.slice(1), {\n\t\tstdio: \"inherit\",\n\t\tenv: env,\n\t});\n\tchild.on(\"exit\", (code, signal) => {\n\t\tprocess.exit(code ?? (signal ? 128 : 1));\n\t});\n});\nserver.on(\"error\", (err) => {\n\tconsole.error(\"Relay error:\", err);\n\tprocess.exit(1);\n});\n`.trim(),\n\t\t\t);\n\n\t\t\tconst readOnlyMounts = [process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])].flatMap((path) =>\n\t\t\t\treadOnlyMountArguments(path),\n\t\t\t);\n\t\t\tconst readOnlyFileMounts = (launch.readOnlyFiles ?? []).flatMap((path, index) =>\n\t\t\t\treadOnlyMountArguments(path, \"file\", index + 3),\n\t\t\t);\n\t\t\tconst readOnlyFileDescriptors = (launch.readOnlyFiles ?? []).map((path) => openSync(path, \"r\"));\n\t\t\t// The descriptors above are opened before the child spawns and must be closed\n\t\t\t// on every exit path -- including a spawn/wait rejection -- or a crashed launch\n\t\t\t// leaks an open handle onto a host-owned credential file for the process's life.\n\t\t\ttry {\n\t\t\t\tconst spawnBwrap = options?.spawnChild ?? spawn;\n\t\t\t\tconst child = spawnBwrap(\n\t\t\t\t\t\"bwrap\",\n\t\t\t\t\t[\n\t\t\t\t\t\t\"--new-session\",\n\t\t\t\t\t\t\"--die-with-parent\",\n\t\t\t\t\t\t\"--unshare-user\",\n\t\t\t\t\t\t\"--unshare-pid\",\n\t\t\t\t\t\t\"--unshare-net\",\n\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"--tmpfs\",\n\t\t\t\t\t\t\"/home\",\n\t\t\t\t\t\t// Immediately after the /home tmpfs: that is the only writable mount at\n\t\t\t\t\t\t// this point, so it is the only place bwrap can create the mountpoint.\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\thostSocketPath,\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t...readOnlyMounts,\n\t\t\t\t\t\t...readOnlyFileMounts,\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t// After the workspace bind: these destinations sit inside it, and an\n\t\t\t\t\t\t// earlier mount would be masked when the workspace is bound over them.\n\t\t\t\t\t\t...(launch.readOnlyBinaries ?? []).flatMap(({ source, destination }) => [\n\t\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\tsource,\n\t\t\t\t\t\t\tdestination,\n\t\t\t\t\t\t]),\n\t\t\t\t\t\t\"--dev\",\n\t\t\t\t\t\t\"/dev\",\n\t\t\t\t\t\t\"--proc\",\n\t\t\t\t\t\t\"/proc\",\n\t\t\t\t\t\t\"--chdir\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"HOME\",\n\t\t\t\t\t\tlaunch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"TMPDIR\",\n\t\t\t\t\t\tlaunch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"APEX_UDS_PATH\",\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t\"--\",\n\t\t\t\t\t\tprocess.execPath,\n\t\t\t\t\t\trelayScriptPath,\n\t\t\t\t\t\tlaunch.command,\n\t\t\t\t\t\t...launch.args,\n\t\t\t\t\t],\n\t\t\t\t\t{ env: launch.environment, stdio: [\"inherit\", \"inherit\", \"pipe\", ...readOnlyFileDescriptors] },\n\t\t\t\t);\n\t\t\t\tlet stderr = \"\";\n\t\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\t\tstderr += chunk;\n\t\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t\t});\n\t\t\t\tconst exitCode = await waitForExit(child);\n\t\t\t\tawait proxy?.close();\n\t\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\t\tif (kind) {\n\t\t\t\t\tviolationStore?.add({\n\t\t\t\t\t\tkind,\n\t\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\treturn exitCode;\n\t\t\t} finally {\n\t\t\t\tfor (const descriptor of readOnlyFileDescriptors) closeSync(descriptor);\n\t\t\t\t// The socket now lives outside the workspace, so nothing else will ever clean\n\t\t\t\t// it up; leaving it would litter the temp directory once per launch.\n\t\t\t\trmSync(hostSocketPath, { force: true });\n\t\t\t}\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
1
+ {"version":3,"file":"linux-backend.js","sourceRoot":"","sources":["../../../src/core/sandbox/linux-backend.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { randomBytes } from \"node:crypto\";\nimport { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport { publishTerminalSize, TERMINAL_SIZE_PATH_VARIABLE } from \"./terminal-size.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\n/** AF_UNIX `sun_path` is 108 bytes on Linux, including the terminating NUL. */\nconst SUN_PATH_LIMIT = 108;\n\nexport interface LinuxSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/** Injectable only to prove crash cleanup in tests; production spawns `bwrap` directly. */\n\tspawnChild?: typeof spawn;\n}\n\nfunction commandExists(command: string): boolean {\n\tconst result = spawnSync(command, [\"--version\"], { stdio: \"ignore\", timeout: 1_000 });\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\nfunction readOnlyMountArguments(path: string, kind: \"directory\" | \"file\" = \"directory\", descriptor = 3): string[] {\n\tconst target = resolve(path);\n\tconst directory = dirname(target);\n\tconst ancestors: string[] = [];\n\tlet current = directory;\n\twhile (current !== \"/\" && current !== \"/home\") {\n\t\tancestors.push(current);\n\t\tcurrent = dirname(current);\n\t}\n\tconst parentArguments = ancestors.reverse().flatMap((ancestor) => [\"--dir\", ancestor]);\n\treturn kind === \"file\"\n\t\t? [...parentArguments, \"--tmpfs\", directory, \"--perms\", \"0400\", \"--file\", String(descriptor), target]\n\t\t: [...parentArguments, \"--ro-bind\", directory, directory];\n}\n\n/**\n * Where the supervisor's proxy socket lives on each side of the boundary.\n *\n * AF_UNIX caps `sun_path` at 108 bytes, so the socket cannot live under the workspace:\n * a deep-but-ordinary project directory exhausts that budget and the sandbox fails to\n * start at all with `listen EINVAL`. The host side therefore gets a short, unique path\n * under the system temp directory, and it is bind-mounted into the child.\n *\n * The child-side path has to sit under `/home`. That is the one writable mount at the\n * point the mountpoint is created — the sandbox root is a read-only bind, so bwrap\n * cannot create a mountpoint at `/run`, `/tmp`, or anywhere else on it.\n */\nexport function resolveProxySocketPaths(temporaryDirectory: string = tmpdir()): {\n\thostSocketPath: string;\n\tchildSocketPath: string;\n} {\n\tconst name = `apex-${process.pid}-${randomBytes(4).toString(\"hex\")}.sock`;\n\t// An unusually long TMPDIR would reintroduce the very limit this avoids.\n\tconst base = join(temporaryDirectory, name).length + 1 > SUN_PATH_LIMIT ? \"/tmp\" : temporaryDirectory;\n\treturn { hostSocketPath: join(base, name), childSocketPath: `/home/${name}` };\n}\n\n/**\n * Classify an unsuccessful child from what its stderr actually evidences, or return\n * undefined when nothing suggests the boundary refused anything.\n *\n * A non-zero exit is not a violation. An invalid API key, a failing test command, or a\n * script exiting 1 are the child's own business, and recording them as sandbox\n * violations blames the boundary for failures it had no part in — which is worse than\n * silence, because it sends whoever reads the output looking for a refusal that never\n * happened. Only Linux's own refusal wording earns a violation here; the proxy records\n * network refusals itself, before this fallback is consulted.\n */\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | undefined {\n\tif (/Read-only file system|Permission denied|Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (/Network is unreachable|ENETUNREACH|EHOSTUNREACH/i.test(stderr)) return \"network\";\n\treturn undefined;\n}\n\n/**\n * Linux's deliberately small platform adapter. The sandbox starts from a read-only\n * host root and bind-mounts only the canonical workspace as writable. `--unshare-net`\n * removes all direct network interfaces for the child and every descendant.\n */\nexport function createLinuxSandboxBackend(options?: LinuxSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"linux\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on Linux only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? commandExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"bwrap\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"Bubblewrap (bwrap) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\tconst stateDirectory = join(launch.policy.workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tconst { hostSocketPath, childSocketPath } = resolveProxySocketPaths();\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\tsocketPath: hostSocketPath,\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\n\t\t\t// .cjs forces CommonJS regardless of the target workspace's package.json \"type\"\n\t\t\t// field -- a plain .js here would be parsed as ESM under \"type\": \"module\" and\n\t\t\t// crash on `require`, since Node resolves module type from the nearest package.json.\n\t\t\tconst relayScriptPath = join(stateDirectory, \"relay.cjs\");\n\t\t\twriteFileSync(\n\t\t\t\trelayScriptPath,\n\t\t\t\t`\nconst net = require(\"node:net\");\nconst child_process = require(\"node:child_process\");\n\nconst socketPath = process.env.APEX_UDS_PATH;\nconst server = net.createServer((c) => {\n\tconst client = net.connect(socketPath);\n\tc.pipe(client).pipe(c);\n\tc.on(\"error\", () => {});\n\tclient.on(\"error\", () => {});\n});\n\nserver.listen(0, \"127.0.0.1\", () => {\n\tconst port = server.address().port;\n\tconst env = Object.assign({}, process.env, {\n\t\tHTTP_PROXY: \\`http://127.0.0.1:\\${port}\\`,\n\t\tHTTPS_PROXY: \\`http://127.0.0.1:\\${port}\\`\n\t});\n\tconst args = process.argv.slice(2);\n\tconst child = child_process.spawn(args[0], args.slice(1), {\n\t\tstdio: \"inherit\",\n\t\tenv: env,\n\t});\n\tchild.on(\"exit\", (code, signal) => {\n\t\tprocess.exit(code ?? (signal ? 128 : 1));\n\t});\n});\nserver.on(\"error\", (err) => {\n\tconsole.error(\"Relay error:\", err);\n\tprocess.exit(1);\n});\n`.trim(),\n\t\t\t);\n\n\t\t\tconst readOnlyMounts = [process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])].flatMap((path) =>\n\t\t\t\treadOnlyMountArguments(path),\n\t\t\t);\n\t\t\tconst readOnlyFileMounts = (launch.readOnlyFiles ?? []).flatMap((path, index) =>\n\t\t\t\treadOnlyMountArguments(path, \"file\", index + 3),\n\t\t\t);\n\t\t\tconst readOnlyFileDescriptors = (launch.readOnlyFiles ?? []).map((path) => openSync(path, \"r\"));\n\t\t\t// The descriptors above are opened before the child spawns and must be closed\n\t\t\t// on every exit path -- including a spawn/wait rejection -- or a crashed launch\n\t\t\t// leaks an open handle onto a host-owned credential file for the process's life.\n\t\t\t// The workspace is bind-mounted read-write into the sandbox, so a file\n\t\t\t// here is visible at the same absolute path on both sides of it.\n\t\t\tconst terminalSizePath = join(stateDirectory, \"terminal-size\");\n\t\t\tconst stopPublishingTerminalSize = publishTerminalSize(terminalSizePath);\n\t\t\ttry {\n\t\t\t\tconst spawnBwrap = options?.spawnChild ?? spawn;\n\t\t\t\tconst child = spawnBwrap(\n\t\t\t\t\t\"bwrap\",\n\t\t\t\t\t[\n\t\t\t\t\t\t\"--new-session\",\n\t\t\t\t\t\t\"--die-with-parent\",\n\t\t\t\t\t\t\"--unshare-user\",\n\t\t\t\t\t\t\"--unshare-pid\",\n\t\t\t\t\t\t\"--unshare-net\",\n\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"--tmpfs\",\n\t\t\t\t\t\t\"/home\",\n\t\t\t\t\t\t// Immediately after the /home tmpfs: that is the only writable mount at\n\t\t\t\t\t\t// this point, so it is the only place bwrap can create the mountpoint.\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\thostSocketPath,\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t// The credential channel socket: same writable-under-/home constraint\n\t\t\t\t\t\t// and same position as the network socket above. Its child-side path\n\t\t\t\t\t\t// is what `APEX_CREDENTIAL_PROXY_PATH` names in the launch environment.\n\t\t\t\t\t\t...(launch.credentialChannel\n\t\t\t\t\t\t\t? [\"--bind\", launch.credentialChannel.hostSocketPath, launch.credentialChannel.childSocketPath]\n\t\t\t\t\t\t\t: []),\n\t\t\t\t\t\t...readOnlyMounts,\n\t\t\t\t\t\t...readOnlyFileMounts,\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t// After the workspace bind: these destinations sit inside it, and an\n\t\t\t\t\t\t// earlier mount would be masked when the workspace is bound over them.\n\t\t\t\t\t\t...(launch.readOnlyBinaries ?? []).flatMap(({ source, destination }) => [\n\t\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\tsource,\n\t\t\t\t\t\t\tdestination,\n\t\t\t\t\t\t]),\n\t\t\t\t\t\t\"--dev\",\n\t\t\t\t\t\t\"/dev\",\n\t\t\t\t\t\t\"--proc\",\n\t\t\t\t\t\t\"/proc\",\n\t\t\t\t\t\t\"--chdir\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"HOME\",\n\t\t\t\t\t\tlaunch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"TMPDIR\",\n\t\t\t\t\t\tlaunch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"APEX_UDS_PATH\",\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\tTERMINAL_SIZE_PATH_VARIABLE,\n\t\t\t\t\t\tterminalSizePath,\n\t\t\t\t\t\t\"--\",\n\t\t\t\t\t\tprocess.execPath,\n\t\t\t\t\t\trelayScriptPath,\n\t\t\t\t\t\tlaunch.command,\n\t\t\t\t\t\t...launch.args,\n\t\t\t\t\t],\n\t\t\t\t\t{ env: launch.environment, stdio: [\"inherit\", \"inherit\", \"pipe\", ...readOnlyFileDescriptors] },\n\t\t\t\t);\n\t\t\t\tlet stderr = \"\";\n\t\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\t\tstderr += chunk;\n\t\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t\t});\n\t\t\t\tconst exitCode = await waitForExit(child);\n\t\t\t\tawait proxy?.close();\n\t\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\t\tif (kind) {\n\t\t\t\t\tviolationStore?.add({\n\t\t\t\t\t\tkind,\n\t\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\treturn exitCode;\n\t\t\t} finally {\n\t\t\t\tstopPublishingTerminalSize();\n\t\t\t\tfor (const descriptor of readOnlyFileDescriptors) closeSync(descriptor);\n\t\t\t\t// The socket now lives outside the workspace, so nothing else will ever clean\n\t\t\t\t// it up; leaving it would litter the temp directory once per launch.\n\t\t\t\trmSync(hostSocketPath, { force: true });\n\t\t\t}\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"macos-backend.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/macos-backend.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAa,MAAM,oBAAoB,CAAC;AAKtD,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,iBAAiB,CAAC;AACrE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAE7D,MAAM,WAAW,0BAA0B;IAC1C,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AA8DD;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,0BAA0B,GAAG,cAAc,CA2I9F","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { mkdirSync, realpathSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface MacosSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/**\n\t * Injectable only so violation attribution can be tested off macOS; production\n\t * spawns `sandbox-exec` directly. Without this the decision about what counts as a\n\t * violation would be verifiable only on a macOS host.\n\t */\n\tspawnChild?: typeof spawn;\n}\n\nfunction sandboxExecExists(command: string): boolean {\n\t// sandbox-exec has no --version flag; a real, minimal profile run is the\n\t// standard existence-and-basic-functionality probe (see the 2b.5 prototype's\n\t// own positive control).\n\tconst result = spawnSync(command, [\"-p\", \"(version 1)(allow default)\", \"/usr/bin/true\"], {\n\t\tstdio: \"ignore\",\n\t\ttimeout: 2_000,\n\t});\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\n/**\n * Unlike Linux's bwrap (which needs each mount ancestor declared for its bind-mount\n * tree), Seatbelt's `subpath` is recursive from a single root, so only the directory\n * containing each path is needed -- no ancestor walk. Each directory is resolved to\n * its real (symlink-free) path: Seatbelt canonicalizes the path it actually checks\n * against a `subpath` rule (confirmed empirically -- a rule built from an\n * unresolved symlinked path, e.g. Homebrew's own layout, silently never matches).\n */\nfunction readOnlyDirectories(paths: readonly string[]): string[] {\n\tconst directories = paths.map((path) => dirname(resolve(path)));\n\tconst canonical = directories.map((directory) => {\n\t\ttry {\n\t\t\treturn realpathSync(directory);\n\t\t} catch {\n\t\t\treturn directory;\n\t\t}\n\t});\n\treturn [...new Set(canonical)];\n}\n\n/** Seatbelt refuses through the blocked call's own errno text, not a wrapper message. */\nconst DENIAL_EVIDENCE = /Operation not permitted|Permission denied|EPERM|EACCES/i;\n\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | \"unknown\" | undefined {\n\t// Confirmed empirically (2b.5 prototype): unlike Linux's bwrap, macOS Seatbelt\n\t// denials surface through the launched command's own generic \"Operation not\n\t// permitted\"/EPERM text for filesystem AND network refusals alike -- there is no\n\t// macOS-specific phrase equivalent to Linux's distinct \"Network is unreachable\".\n\t// Only sandbox-exec's own wrapper-level exec refusal (a sign our own profile\n\t// forgot to allow a binary, not a policy decision) is unambiguous from stderr\n\t// text. Anything else that still shows a refusal stays \"unknown\";\n\t// network-proxy.ts's own precise per-request recording is the reliable \"network\"\n\t// signal on this platform.\n\tif (/execvp\\(\\) of .* failed: Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (DENIAL_EVIDENCE.test(stderr)) return \"unknown\";\n\t// No refusal in evidence. The coarseness above is about telling filesystem from\n\t// network, not about telling a denial from an ordinary failure -- a non-zero exit\n\t// on its own says nothing about the boundary, and reporting it as a violation\n\t// sends the reader looking for a refusal that never happened.\n\treturn undefined;\n}\n\n/**\n * macOS's platform adapter. Seatbelt (`sandbox-exec`) allows reads broadly except\n * the invoking account's home directory, denies writes outside the workspace, and\n * denies network outside the sandbox proxy's exact loopback port -- narrower than\n * the wildcard `localhost:*` pattern some other sandboxes use. This mirrors Linux's\n * actual posture (`--ro-bind / /` plus a hidden `/home`) rather than the narrower\n * read-allowlist this design started with, which broke ordinary process startup.\n */\nexport function createMacosSandboxBackend(options?: MacosSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"darwin\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on macOS only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? sandboxExecExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"sandbox-exec\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"sandbox-exec (Seatbelt) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\t// Same reasoning as readOnlyDirectories: on macOS the workspace itself can\n\t\t\t// be reached through a symlink (e.g. os.tmpdir()'s /var -> /private/var),\n\t\t\t// and Seatbelt's subpath match is against the canonical path, not the one\n\t\t\t// the caller happened to spell. Resolve once, use everywhere below.\n\t\t\tconst workspace = realpathSync(launch.policy.workspace);\n\t\t\tconst stateDirectory = join(workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\ttcpHost: \"127.0.0.1\",\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\t\t\tconst proxyPort = proxy.port as number;\n\n\t\t\tconst readOnlyDirs = readOnlyDirectories([process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])]);\n\t\t\tconst readOnlyFiles = (launch.readOnlyFiles ?? []).map((path) => {\n\t\t\t\ttry {\n\t\t\t\t\treturn realpathSync(path);\n\t\t\t\t} catch {\n\t\t\t\t\treturn resolve(path);\n\t\t\t\t}\n\t\t\t});\n\t\t\tlet userHome: string | undefined;\n\t\t\ttry {\n\t\t\t\tuserHome = realpathSync(homedir());\n\t\t\t} catch {\n\t\t\t\tuserHome = homedir();\n\t\t\t}\n\n\t\t\tconst profileLines = [\n\t\t\t\t\"(version 1)\",\n\t\t\t\t// bsd.sb's baseline is broader than Linux's opt-in bind-mount model --\n\t\t\t\t// a real gap this design accepts, documented in the spec's fourth\n\t\t\t\t// amendment -- but it is the only combination the 2b.5 prototype found\n\t\t\t\t// that lets a normal Unix child process (dyld, /bin/sh) actually start.\n\t\t\t\t'(import \"bsd.sb\")',\n\t\t\t\t\"(allow process-exec*)\",\n\t\t\t\t// Separate from process-exec* -- confirmed empirically: a shell that\n\t\t\t\t// forks a child shell (e.g. `sh -c 'a' && sh -c 'b'`) failed with\n\t\t\t\t// \"fork: Operation not permitted\" without this, even with exec allowed.\n\t\t\t\t\"(allow process-fork)\",\n\t\t\t\t// Matches Linux's actual posture (bwrap's `--ro-bind / /`): broad read,\n\t\t\t\t// narrow write. A read-only allowlist alone (this design's first cut)\n\t\t\t\t// breaks ordinary process startup -- confirmed empirically, getcwd()\n\t\t\t\t// itself needs to traverse the workspace's ancestor directories, which\n\t\t\t\t// no read rule scoped only to the workspace or runtime paths covers.\n\t\t\t\t\"(allow file-read*)\",\n\t\t\t\t'(deny file-read* (subpath (param \"USER_HOME\")))',\n\t\t\t\t...readOnlyDirs.map((_, index) => `(allow file-read* (subpath (param \"RO_${index}\")))`),\n\t\t\t\t...readOnlyFiles.map((_, index) => `(allow file-read* (subpath (param \"RO_FILE_${index}\")))`),\n\t\t\t\t'(allow file-read* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny file-write*)\",\n\t\t\t\t'(allow file-write* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny network*)\",\n\t\t\t\t'(allow network-outbound (remote ip (param \"PROXY_ADDR\")))',\n\t\t\t];\n\t\t\tconst profilePath = join(stateDirectory, \"profile.sb\");\n\t\t\twriteFileSync(profilePath, profileLines.join(\"\\n\"));\n\n\t\t\tconst params: string[] = [\"-D\", `USER_HOME=${userHome}`];\n\t\t\treadOnlyDirs.forEach((dir, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_${index}=${dir}`);\n\t\t\t});\n\t\t\treadOnlyFiles.forEach((file, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_FILE_${index}=${file}`);\n\t\t\t});\n\t\t\tparams.push(\"-D\", `WORKSPACE=${workspace}`);\n\t\t\tparams.push(\"-D\", `PROXY_ADDR=localhost:${proxyPort}`);\n\n\t\t\tconst spawnSandboxExec = options?.spawnChild ?? spawn;\n\t\t\tconst child = spawnSandboxExec(\n\t\t\t\t\"sandbox-exec\",\n\t\t\t\t[\"-f\", profilePath, ...params, \"--\", launch.command, ...launch.args],\n\t\t\t\t{\n\t\t\t\t\tcwd: workspace,\n\t\t\t\t\tenv: {\n\t\t\t\t\t\t...launch.environment,\n\t\t\t\t\t\tHOME: launch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\tTMPDIR: launch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\tHTTP_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\t\tHTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\t},\n\t\t\t\t\tstdio: [\"inherit\", \"inherit\", \"pipe\"],\n\t\t\t\t},\n\t\t\t);\n\t\t\tlet stderr = \"\";\n\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\tstderr += chunk;\n\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t});\n\t\t\tconst exitCode = await waitForExit(child);\n\t\t\tawait proxy?.close();\n\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\tif (kind) {\n\t\t\t\tviolationStore?.add({\n\t\t\t\t\tkind,\n\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn exitCode;\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
1
+ {"version":3,"file":"macos-backend.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/macos-backend.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAa,MAAM,oBAAoB,CAAC;AAKtD,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,iBAAiB,CAAC;AACrE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAE7D,MAAM,WAAW,0BAA0B;IAC1C,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AA6ED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,0BAA0B,GAAG,cAAc,CAoJ9F","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { mkdirSync, realpathSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface MacosSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/**\n\t * Injectable only so violation attribution can be tested off macOS; production\n\t * spawns `sandbox-exec` directly. Without this the decision about what counts as a\n\t * violation would be verifiable only on a macOS host.\n\t */\n\tspawnChild?: typeof spawn;\n}\n\nfunction sandboxExecExists(command: string): boolean {\n\t// sandbox-exec has no --version flag; a real, minimal profile run is the\n\t// standard existence-and-basic-functionality probe (see the 2b.5 prototype's\n\t// own positive control).\n\tconst result = spawnSync(command, [\"-p\", \"(version 1)(allow default)\", \"/usr/bin/true\"], {\n\t\tstdio: \"ignore\",\n\t\ttimeout: 2_000,\n\t});\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\n/**\n * Unlike Linux's bwrap (which needs each mount ancestor declared for its bind-mount\n * tree), Seatbelt's `subpath` is recursive from a single root, so only the directory\n * containing each path is needed -- no ancestor walk. Each directory is resolved to\n * its real (symlink-free) path: Seatbelt canonicalizes the path it actually checks\n * against a `subpath` rule (confirmed empirically -- a rule built from an\n * unresolved symlinked path, e.g. Homebrew's own layout, silently never matches).\n */\nfunction readOnlyDirectories(paths: readonly string[]): string[] {\n\tconst directories = paths.map((path) => dirname(resolve(path)));\n\tconst canonical = directories.map((directory) => {\n\t\ttry {\n\t\t\treturn realpathSync(directory);\n\t\t} catch {\n\t\t\treturn directory;\n\t\t}\n\t});\n\treturn [...new Set(canonical)];\n}\n\n/**\n * Canonicalize a channel socket path for a Seatbelt literal. Only the directory can be\n * resolved -- the socket file need not exist yet when the profile is written -- and the\n * same symlink rule as `readOnlyDirectories` applies: Seatbelt matches the canonical\n * path, so an unresolved one silently never matches.\n */\nfunction canonicalSocketPath(path: string): string {\n\tconst directory = dirname(resolve(path));\n\ttry {\n\t\treturn join(realpathSync(directory), basename(path));\n\t} catch {\n\t\treturn join(directory, basename(path));\n\t}\n}\n\n/** Seatbelt refuses through the blocked call's own errno text, not a wrapper message. */\nconst DENIAL_EVIDENCE = /Operation not permitted|Permission denied|EPERM|EACCES/i;\n\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | \"unknown\" | undefined {\n\t// Confirmed empirically (2b.5 prototype): unlike Linux's bwrap, macOS Seatbelt\n\t// denials surface through the launched command's own generic \"Operation not\n\t// permitted\"/EPERM text for filesystem AND network refusals alike -- there is no\n\t// macOS-specific phrase equivalent to Linux's distinct \"Network is unreachable\".\n\t// Only sandbox-exec's own wrapper-level exec refusal (a sign our own profile\n\t// forgot to allow a binary, not a policy decision) is unambiguous from stderr\n\t// text. Anything else that still shows a refusal stays \"unknown\";\n\t// network-proxy.ts's own precise per-request recording is the reliable \"network\"\n\t// signal on this platform.\n\tif (/execvp\\(\\) of .* failed: Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (DENIAL_EVIDENCE.test(stderr)) return \"unknown\";\n\t// No refusal in evidence. The coarseness above is about telling filesystem from\n\t// network, not about telling a denial from an ordinary failure -- a non-zero exit\n\t// on its own says nothing about the boundary, and reporting it as a violation\n\t// sends the reader looking for a refusal that never happened.\n\treturn undefined;\n}\n\n/**\n * macOS's platform adapter. Seatbelt (`sandbox-exec`) allows reads broadly except\n * the invoking account's home directory, denies writes outside the workspace, and\n * denies network outside the sandbox proxy's exact loopback port -- narrower than\n * the wildcard `localhost:*` pattern some other sandboxes use. This mirrors Linux's\n * actual posture (`--ro-bind / /` plus a hidden `/home`) rather than the narrower\n * read-allowlist this design started with, which broke ordinary process startup.\n */\nexport function createMacosSandboxBackend(options?: MacosSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"darwin\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on macOS only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? sandboxExecExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"sandbox-exec\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"sandbox-exec (Seatbelt) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\t// Same reasoning as readOnlyDirectories: on macOS the workspace itself can\n\t\t\t// be reached through a symlink (e.g. os.tmpdir()'s /var -> /private/var),\n\t\t\t// and Seatbelt's subpath match is against the canonical path, not the one\n\t\t\t// the caller happened to spell. Resolve once, use everywhere below.\n\t\t\tconst workspace = realpathSync(launch.policy.workspace);\n\t\t\tconst stateDirectory = join(workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\ttcpHost: \"127.0.0.1\",\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\t\t\tconst proxyPort = proxy.port as number;\n\n\t\t\t// Seatbelt cannot remap a path, so the child connects to the channel socket at\n\t\t\t// its host path, and the profile allows outbound to exactly that one socket.\n\t\t\tconst credentialChannelSocket = launch.credentialChannel\n\t\t\t\t? canonicalSocketPath(launch.credentialChannel.hostSocketPath)\n\t\t\t\t: undefined;\n\n\t\t\tconst readOnlyDirs = readOnlyDirectories([process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])]);\n\t\t\tconst readOnlyFiles = (launch.readOnlyFiles ?? []).map((path) => {\n\t\t\t\ttry {\n\t\t\t\t\treturn realpathSync(path);\n\t\t\t\t} catch {\n\t\t\t\t\treturn resolve(path);\n\t\t\t\t}\n\t\t\t});\n\t\t\tlet userHome: string | undefined;\n\t\t\ttry {\n\t\t\t\tuserHome = realpathSync(homedir());\n\t\t\t} catch {\n\t\t\t\tuserHome = homedir();\n\t\t\t}\n\n\t\t\tconst profileLines = [\n\t\t\t\t\"(version 1)\",\n\t\t\t\t// bsd.sb's baseline is broader than Linux's opt-in bind-mount model --\n\t\t\t\t// a real gap this design accepts, documented in the spec's fourth\n\t\t\t\t// amendment -- but it is the only combination the 2b.5 prototype found\n\t\t\t\t// that lets a normal Unix child process (dyld, /bin/sh) actually start.\n\t\t\t\t'(import \"bsd.sb\")',\n\t\t\t\t\"(allow process-exec*)\",\n\t\t\t\t// Separate from process-exec* -- confirmed empirically: a shell that\n\t\t\t\t// forks a child shell (e.g. `sh -c 'a' && sh -c 'b'`) failed with\n\t\t\t\t// \"fork: Operation not permitted\" without this, even with exec allowed.\n\t\t\t\t\"(allow process-fork)\",\n\t\t\t\t// Matches Linux's actual posture (bwrap's `--ro-bind / /`): broad read,\n\t\t\t\t// narrow write. A read-only allowlist alone (this design's first cut)\n\t\t\t\t// breaks ordinary process startup -- confirmed empirically, getcwd()\n\t\t\t\t// itself needs to traverse the workspace's ancestor directories, which\n\t\t\t\t// no read rule scoped only to the workspace or runtime paths covers.\n\t\t\t\t\"(allow file-read*)\",\n\t\t\t\t'(deny file-read* (subpath (param \"USER_HOME\")))',\n\t\t\t\t...readOnlyDirs.map((_, index) => `(allow file-read* (subpath (param \"RO_${index}\")))`),\n\t\t\t\t...readOnlyFiles.map((_, index) => `(allow file-read* (subpath (param \"RO_FILE_${index}\")))`),\n\t\t\t\t'(allow file-read* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny file-write*)\",\n\t\t\t\t'(allow file-write* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny network*)\",\n\t\t\t\t'(allow network-outbound (remote ip (param \"PROXY_ADDR\")))',\n\t\t\t\t...(credentialChannelSocket\n\t\t\t\t\t? [`(allow network-outbound (remote unix-socket (literal \"${credentialChannelSocket}\")))`]\n\t\t\t\t\t: []),\n\t\t\t];\n\t\t\tconst profilePath = join(stateDirectory, \"profile.sb\");\n\t\t\twriteFileSync(profilePath, profileLines.join(\"\\n\"));\n\n\t\t\tconst params: string[] = [\"-D\", `USER_HOME=${userHome}`];\n\t\t\treadOnlyDirs.forEach((dir, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_${index}=${dir}`);\n\t\t\t});\n\t\t\treadOnlyFiles.forEach((file, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_FILE_${index}=${file}`);\n\t\t\t});\n\t\t\tparams.push(\"-D\", `WORKSPACE=${workspace}`);\n\t\t\tparams.push(\"-D\", `PROXY_ADDR=localhost:${proxyPort}`);\n\n\t\t\tconst spawnSandboxExec = options?.spawnChild ?? spawn;\n\t\t\tconst child = spawnSandboxExec(\n\t\t\t\t\"sandbox-exec\",\n\t\t\t\t[\"-f\", profilePath, ...params, \"--\", launch.command, ...launch.args],\n\t\t\t\t{\n\t\t\t\t\tcwd: workspace,\n\t\t\t\t\tenv: {\n\t\t\t\t\t\t...launch.environment,\n\t\t\t\t\t\tHOME: launch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\tTMPDIR: launch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\tHTTP_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\t\tHTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\t},\n\t\t\t\t\tstdio: [\"inherit\", \"inherit\", \"pipe\"],\n\t\t\t\t},\n\t\t\t);\n\t\t\tlet stderr = \"\";\n\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\tstderr += chunk;\n\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t});\n\t\t\tconst exitCode = await waitForExit(child);\n\t\t\tawait proxy?.close();\n\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\tif (kind) {\n\t\t\t\tviolationStore?.add({\n\t\t\t\t\tkind,\n\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn exitCode;\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
@@ -1,7 +1,7 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { createSandboxNetworkProxy } from "./network-proxy.js";
6
6
  function sandboxExecExists(command) {
7
7
  // sandbox-exec has no --version flag; a real, minimal profile run is the
@@ -39,6 +39,21 @@ function readOnlyDirectories(paths) {
39
39
  });
40
40
  return [...new Set(canonical)];
41
41
  }
42
+ /**
43
+ * Canonicalize a channel socket path for a Seatbelt literal. Only the directory can be
44
+ * resolved -- the socket file need not exist yet when the profile is written -- and the
45
+ * same symlink rule as `readOnlyDirectories` applies: Seatbelt matches the canonical
46
+ * path, so an unresolved one silently never matches.
47
+ */
48
+ function canonicalSocketPath(path) {
49
+ const directory = dirname(resolve(path));
50
+ try {
51
+ return join(realpathSync(directory), basename(path));
52
+ }
53
+ catch {
54
+ return join(directory, basename(path));
55
+ }
56
+ }
42
57
  /** Seatbelt refuses through the blocked call's own errno text, not a wrapper message. */
43
58
  const DENIAL_EVIDENCE = /Operation not permitted|Permission denied|EPERM|EACCES/i;
44
59
  function classifySandboxFailure(stderr) {
@@ -109,6 +124,11 @@ export function createMacosSandboxBackend(options) {
109
124
  violationStore,
110
125
  });
111
126
  const proxyPort = proxy.port;
127
+ // Seatbelt cannot remap a path, so the child connects to the channel socket at
128
+ // its host path, and the profile allows outbound to exactly that one socket.
129
+ const credentialChannelSocket = launch.credentialChannel
130
+ ? canonicalSocketPath(launch.credentialChannel.hostSocketPath)
131
+ : undefined;
112
132
  const readOnlyDirs = readOnlyDirectories([process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])]);
113
133
  const readOnlyFiles = (launch.readOnlyFiles ?? []).map((path) => {
114
134
  try {
@@ -151,6 +171,9 @@ export function createMacosSandboxBackend(options) {
151
171
  '(allow file-write* (subpath (param "WORKSPACE")))',
152
172
  "(deny network*)",
153
173
  '(allow network-outbound (remote ip (param "PROXY_ADDR")))',
174
+ ...(credentialChannelSocket
175
+ ? [`(allow network-outbound (remote unix-socket (literal "${credentialChannelSocket}")))`]
176
+ : []),
154
177
  ];
155
178
  const profilePath = join(stateDirectory, "profile.sb");
156
179
  writeFileSync(profilePath, profileLines.join("\n"));
@@ -1 +1 @@
1
- {"version":3,"file":"macos-backend.js","sourceRoot":"","sources":["../../../src/core/sandbox/macos-backend.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { mkdirSync, realpathSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface MacosSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/**\n\t * Injectable only so violation attribution can be tested off macOS; production\n\t * spawns `sandbox-exec` directly. Without this the decision about what counts as a\n\t * violation would be verifiable only on a macOS host.\n\t */\n\tspawnChild?: typeof spawn;\n}\n\nfunction sandboxExecExists(command: string): boolean {\n\t// sandbox-exec has no --version flag; a real, minimal profile run is the\n\t// standard existence-and-basic-functionality probe (see the 2b.5 prototype's\n\t// own positive control).\n\tconst result = spawnSync(command, [\"-p\", \"(version 1)(allow default)\", \"/usr/bin/true\"], {\n\t\tstdio: \"ignore\",\n\t\ttimeout: 2_000,\n\t});\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\n/**\n * Unlike Linux's bwrap (which needs each mount ancestor declared for its bind-mount\n * tree), Seatbelt's `subpath` is recursive from a single root, so only the directory\n * containing each path is needed -- no ancestor walk. Each directory is resolved to\n * its real (symlink-free) path: Seatbelt canonicalizes the path it actually checks\n * against a `subpath` rule (confirmed empirically -- a rule built from an\n * unresolved symlinked path, e.g. Homebrew's own layout, silently never matches).\n */\nfunction readOnlyDirectories(paths: readonly string[]): string[] {\n\tconst directories = paths.map((path) => dirname(resolve(path)));\n\tconst canonical = directories.map((directory) => {\n\t\ttry {\n\t\t\treturn realpathSync(directory);\n\t\t} catch {\n\t\t\treturn directory;\n\t\t}\n\t});\n\treturn [...new Set(canonical)];\n}\n\n/** Seatbelt refuses through the blocked call's own errno text, not a wrapper message. */\nconst DENIAL_EVIDENCE = /Operation not permitted|Permission denied|EPERM|EACCES/i;\n\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | \"unknown\" | undefined {\n\t// Confirmed empirically (2b.5 prototype): unlike Linux's bwrap, macOS Seatbelt\n\t// denials surface through the launched command's own generic \"Operation not\n\t// permitted\"/EPERM text for filesystem AND network refusals alike -- there is no\n\t// macOS-specific phrase equivalent to Linux's distinct \"Network is unreachable\".\n\t// Only sandbox-exec's own wrapper-level exec refusal (a sign our own profile\n\t// forgot to allow a binary, not a policy decision) is unambiguous from stderr\n\t// text. Anything else that still shows a refusal stays \"unknown\";\n\t// network-proxy.ts's own precise per-request recording is the reliable \"network\"\n\t// signal on this platform.\n\tif (/execvp\\(\\) of .* failed: Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (DENIAL_EVIDENCE.test(stderr)) return \"unknown\";\n\t// No refusal in evidence. The coarseness above is about telling filesystem from\n\t// network, not about telling a denial from an ordinary failure -- a non-zero exit\n\t// on its own says nothing about the boundary, and reporting it as a violation\n\t// sends the reader looking for a refusal that never happened.\n\treturn undefined;\n}\n\n/**\n * macOS's platform adapter. Seatbelt (`sandbox-exec`) allows reads broadly except\n * the invoking account's home directory, denies writes outside the workspace, and\n * denies network outside the sandbox proxy's exact loopback port -- narrower than\n * the wildcard `localhost:*` pattern some other sandboxes use. This mirrors Linux's\n * actual posture (`--ro-bind / /` plus a hidden `/home`) rather than the narrower\n * read-allowlist this design started with, which broke ordinary process startup.\n */\nexport function createMacosSandboxBackend(options?: MacosSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"darwin\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on macOS only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? sandboxExecExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"sandbox-exec\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"sandbox-exec (Seatbelt) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\t// Same reasoning as readOnlyDirectories: on macOS the workspace itself can\n\t\t\t// be reached through a symlink (e.g. os.tmpdir()'s /var -> /private/var),\n\t\t\t// and Seatbelt's subpath match is against the canonical path, not the one\n\t\t\t// the caller happened to spell. Resolve once, use everywhere below.\n\t\t\tconst workspace = realpathSync(launch.policy.workspace);\n\t\t\tconst stateDirectory = join(workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\ttcpHost: \"127.0.0.1\",\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\t\t\tconst proxyPort = proxy.port as number;\n\n\t\t\tconst readOnlyDirs = readOnlyDirectories([process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])]);\n\t\t\tconst readOnlyFiles = (launch.readOnlyFiles ?? []).map((path) => {\n\t\t\t\ttry {\n\t\t\t\t\treturn realpathSync(path);\n\t\t\t\t} catch {\n\t\t\t\t\treturn resolve(path);\n\t\t\t\t}\n\t\t\t});\n\t\t\tlet userHome: string | undefined;\n\t\t\ttry {\n\t\t\t\tuserHome = realpathSync(homedir());\n\t\t\t} catch {\n\t\t\t\tuserHome = homedir();\n\t\t\t}\n\n\t\t\tconst profileLines = [\n\t\t\t\t\"(version 1)\",\n\t\t\t\t// bsd.sb's baseline is broader than Linux's opt-in bind-mount model --\n\t\t\t\t// a real gap this design accepts, documented in the spec's fourth\n\t\t\t\t// amendment -- but it is the only combination the 2b.5 prototype found\n\t\t\t\t// that lets a normal Unix child process (dyld, /bin/sh) actually start.\n\t\t\t\t'(import \"bsd.sb\")',\n\t\t\t\t\"(allow process-exec*)\",\n\t\t\t\t// Separate from process-exec* -- confirmed empirically: a shell that\n\t\t\t\t// forks a child shell (e.g. `sh -c 'a' && sh -c 'b'`) failed with\n\t\t\t\t// \"fork: Operation not permitted\" without this, even with exec allowed.\n\t\t\t\t\"(allow process-fork)\",\n\t\t\t\t// Matches Linux's actual posture (bwrap's `--ro-bind / /`): broad read,\n\t\t\t\t// narrow write. A read-only allowlist alone (this design's first cut)\n\t\t\t\t// breaks ordinary process startup -- confirmed empirically, getcwd()\n\t\t\t\t// itself needs to traverse the workspace's ancestor directories, which\n\t\t\t\t// no read rule scoped only to the workspace or runtime paths covers.\n\t\t\t\t\"(allow file-read*)\",\n\t\t\t\t'(deny file-read* (subpath (param \"USER_HOME\")))',\n\t\t\t\t...readOnlyDirs.map((_, index) => `(allow file-read* (subpath (param \"RO_${index}\")))`),\n\t\t\t\t...readOnlyFiles.map((_, index) => `(allow file-read* (subpath (param \"RO_FILE_${index}\")))`),\n\t\t\t\t'(allow file-read* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny file-write*)\",\n\t\t\t\t'(allow file-write* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny network*)\",\n\t\t\t\t'(allow network-outbound (remote ip (param \"PROXY_ADDR\")))',\n\t\t\t];\n\t\t\tconst profilePath = join(stateDirectory, \"profile.sb\");\n\t\t\twriteFileSync(profilePath, profileLines.join(\"\\n\"));\n\n\t\t\tconst params: string[] = [\"-D\", `USER_HOME=${userHome}`];\n\t\t\treadOnlyDirs.forEach((dir, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_${index}=${dir}`);\n\t\t\t});\n\t\t\treadOnlyFiles.forEach((file, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_FILE_${index}=${file}`);\n\t\t\t});\n\t\t\tparams.push(\"-D\", `WORKSPACE=${workspace}`);\n\t\t\tparams.push(\"-D\", `PROXY_ADDR=localhost:${proxyPort}`);\n\n\t\t\tconst spawnSandboxExec = options?.spawnChild ?? spawn;\n\t\t\tconst child = spawnSandboxExec(\n\t\t\t\t\"sandbox-exec\",\n\t\t\t\t[\"-f\", profilePath, ...params, \"--\", launch.command, ...launch.args],\n\t\t\t\t{\n\t\t\t\t\tcwd: workspace,\n\t\t\t\t\tenv: {\n\t\t\t\t\t\t...launch.environment,\n\t\t\t\t\t\tHOME: launch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\tTMPDIR: launch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\tHTTP_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\t\tHTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\t},\n\t\t\t\t\tstdio: [\"inherit\", \"inherit\", \"pipe\"],\n\t\t\t\t},\n\t\t\t);\n\t\t\tlet stderr = \"\";\n\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\tstderr += chunk;\n\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t});\n\t\t\tconst exitCode = await waitForExit(child);\n\t\t\tawait proxy?.close();\n\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\tif (kind) {\n\t\t\t\tviolationStore?.add({\n\t\t\t\t\tkind,\n\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn exitCode;\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
1
+ {"version":3,"file":"macos-backend.js","sourceRoot":"","sources":["../../../src/core/sandbox/macos-backend.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { mkdirSync, realpathSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface MacosSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/**\n\t * Injectable only so violation attribution can be tested off macOS; production\n\t * spawns `sandbox-exec` directly. Without this the decision about what counts as a\n\t * violation would be verifiable only on a macOS host.\n\t */\n\tspawnChild?: typeof spawn;\n}\n\nfunction sandboxExecExists(command: string): boolean {\n\t// sandbox-exec has no --version flag; a real, minimal profile run is the\n\t// standard existence-and-basic-functionality probe (see the 2b.5 prototype's\n\t// own positive control).\n\tconst result = spawnSync(command, [\"-p\", \"(version 1)(allow default)\", \"/usr/bin/true\"], {\n\t\tstdio: \"ignore\",\n\t\ttimeout: 2_000,\n\t});\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\n/**\n * Unlike Linux's bwrap (which needs each mount ancestor declared for its bind-mount\n * tree), Seatbelt's `subpath` is recursive from a single root, so only the directory\n * containing each path is needed -- no ancestor walk. Each directory is resolved to\n * its real (symlink-free) path: Seatbelt canonicalizes the path it actually checks\n * against a `subpath` rule (confirmed empirically -- a rule built from an\n * unresolved symlinked path, e.g. Homebrew's own layout, silently never matches).\n */\nfunction readOnlyDirectories(paths: readonly string[]): string[] {\n\tconst directories = paths.map((path) => dirname(resolve(path)));\n\tconst canonical = directories.map((directory) => {\n\t\ttry {\n\t\t\treturn realpathSync(directory);\n\t\t} catch {\n\t\t\treturn directory;\n\t\t}\n\t});\n\treturn [...new Set(canonical)];\n}\n\n/**\n * Canonicalize a channel socket path for a Seatbelt literal. Only the directory can be\n * resolved -- the socket file need not exist yet when the profile is written -- and the\n * same symlink rule as `readOnlyDirectories` applies: Seatbelt matches the canonical\n * path, so an unresolved one silently never matches.\n */\nfunction canonicalSocketPath(path: string): string {\n\tconst directory = dirname(resolve(path));\n\ttry {\n\t\treturn join(realpathSync(directory), basename(path));\n\t} catch {\n\t\treturn join(directory, basename(path));\n\t}\n}\n\n/** Seatbelt refuses through the blocked call's own errno text, not a wrapper message. */\nconst DENIAL_EVIDENCE = /Operation not permitted|Permission denied|EPERM|EACCES/i;\n\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | \"unknown\" | undefined {\n\t// Confirmed empirically (2b.5 prototype): unlike Linux's bwrap, macOS Seatbelt\n\t// denials surface through the launched command's own generic \"Operation not\n\t// permitted\"/EPERM text for filesystem AND network refusals alike -- there is no\n\t// macOS-specific phrase equivalent to Linux's distinct \"Network is unreachable\".\n\t// Only sandbox-exec's own wrapper-level exec refusal (a sign our own profile\n\t// forgot to allow a binary, not a policy decision) is unambiguous from stderr\n\t// text. Anything else that still shows a refusal stays \"unknown\";\n\t// network-proxy.ts's own precise per-request recording is the reliable \"network\"\n\t// signal on this platform.\n\tif (/execvp\\(\\) of .* failed: Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (DENIAL_EVIDENCE.test(stderr)) return \"unknown\";\n\t// No refusal in evidence. The coarseness above is about telling filesystem from\n\t// network, not about telling a denial from an ordinary failure -- a non-zero exit\n\t// on its own says nothing about the boundary, and reporting it as a violation\n\t// sends the reader looking for a refusal that never happened.\n\treturn undefined;\n}\n\n/**\n * macOS's platform adapter. Seatbelt (`sandbox-exec`) allows reads broadly except\n * the invoking account's home directory, denies writes outside the workspace, and\n * denies network outside the sandbox proxy's exact loopback port -- narrower than\n * the wildcard `localhost:*` pattern some other sandboxes use. This mirrors Linux's\n * actual posture (`--ro-bind / /` plus a hidden `/home`) rather than the narrower\n * read-allowlist this design started with, which broke ordinary process startup.\n */\nexport function createMacosSandboxBackend(options?: MacosSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"darwin\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on macOS only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? sandboxExecExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"sandbox-exec\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"sandbox-exec (Seatbelt) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\t// Same reasoning as readOnlyDirectories: on macOS the workspace itself can\n\t\t\t// be reached through a symlink (e.g. os.tmpdir()'s /var -> /private/var),\n\t\t\t// and Seatbelt's subpath match is against the canonical path, not the one\n\t\t\t// the caller happened to spell. Resolve once, use everywhere below.\n\t\t\tconst workspace = realpathSync(launch.policy.workspace);\n\t\t\tconst stateDirectory = join(workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\ttcpHost: \"127.0.0.1\",\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\t\t\tconst proxyPort = proxy.port as number;\n\n\t\t\t// Seatbelt cannot remap a path, so the child connects to the channel socket at\n\t\t\t// its host path, and the profile allows outbound to exactly that one socket.\n\t\t\tconst credentialChannelSocket = launch.credentialChannel\n\t\t\t\t? canonicalSocketPath(launch.credentialChannel.hostSocketPath)\n\t\t\t\t: undefined;\n\n\t\t\tconst readOnlyDirs = readOnlyDirectories([process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])]);\n\t\t\tconst readOnlyFiles = (launch.readOnlyFiles ?? []).map((path) => {\n\t\t\t\ttry {\n\t\t\t\t\treturn realpathSync(path);\n\t\t\t\t} catch {\n\t\t\t\t\treturn resolve(path);\n\t\t\t\t}\n\t\t\t});\n\t\t\tlet userHome: string | undefined;\n\t\t\ttry {\n\t\t\t\tuserHome = realpathSync(homedir());\n\t\t\t} catch {\n\t\t\t\tuserHome = homedir();\n\t\t\t}\n\n\t\t\tconst profileLines = [\n\t\t\t\t\"(version 1)\",\n\t\t\t\t// bsd.sb's baseline is broader than Linux's opt-in bind-mount model --\n\t\t\t\t// a real gap this design accepts, documented in the spec's fourth\n\t\t\t\t// amendment -- but it is the only combination the 2b.5 prototype found\n\t\t\t\t// that lets a normal Unix child process (dyld, /bin/sh) actually start.\n\t\t\t\t'(import \"bsd.sb\")',\n\t\t\t\t\"(allow process-exec*)\",\n\t\t\t\t// Separate from process-exec* -- confirmed empirically: a shell that\n\t\t\t\t// forks a child shell (e.g. `sh -c 'a' && sh -c 'b'`) failed with\n\t\t\t\t// \"fork: Operation not permitted\" without this, even with exec allowed.\n\t\t\t\t\"(allow process-fork)\",\n\t\t\t\t// Matches Linux's actual posture (bwrap's `--ro-bind / /`): broad read,\n\t\t\t\t// narrow write. A read-only allowlist alone (this design's first cut)\n\t\t\t\t// breaks ordinary process startup -- confirmed empirically, getcwd()\n\t\t\t\t// itself needs to traverse the workspace's ancestor directories, which\n\t\t\t\t// no read rule scoped only to the workspace or runtime paths covers.\n\t\t\t\t\"(allow file-read*)\",\n\t\t\t\t'(deny file-read* (subpath (param \"USER_HOME\")))',\n\t\t\t\t...readOnlyDirs.map((_, index) => `(allow file-read* (subpath (param \"RO_${index}\")))`),\n\t\t\t\t...readOnlyFiles.map((_, index) => `(allow file-read* (subpath (param \"RO_FILE_${index}\")))`),\n\t\t\t\t'(allow file-read* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny file-write*)\",\n\t\t\t\t'(allow file-write* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny network*)\",\n\t\t\t\t'(allow network-outbound (remote ip (param \"PROXY_ADDR\")))',\n\t\t\t\t...(credentialChannelSocket\n\t\t\t\t\t? [`(allow network-outbound (remote unix-socket (literal \"${credentialChannelSocket}\")))`]\n\t\t\t\t\t: []),\n\t\t\t];\n\t\t\tconst profilePath = join(stateDirectory, \"profile.sb\");\n\t\t\twriteFileSync(profilePath, profileLines.join(\"\\n\"));\n\n\t\t\tconst params: string[] = [\"-D\", `USER_HOME=${userHome}`];\n\t\t\treadOnlyDirs.forEach((dir, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_${index}=${dir}`);\n\t\t\t});\n\t\t\treadOnlyFiles.forEach((file, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_FILE_${index}=${file}`);\n\t\t\t});\n\t\t\tparams.push(\"-D\", `WORKSPACE=${workspace}`);\n\t\t\tparams.push(\"-D\", `PROXY_ADDR=localhost:${proxyPort}`);\n\n\t\t\tconst spawnSandboxExec = options?.spawnChild ?? spawn;\n\t\t\tconst child = spawnSandboxExec(\n\t\t\t\t\"sandbox-exec\",\n\t\t\t\t[\"-f\", profilePath, ...params, \"--\", launch.command, ...launch.args],\n\t\t\t\t{\n\t\t\t\t\tcwd: workspace,\n\t\t\t\t\tenv: {\n\t\t\t\t\t\t...launch.environment,\n\t\t\t\t\t\tHOME: launch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\tTMPDIR: launch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\tHTTP_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\t\tHTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\t},\n\t\t\t\t\tstdio: [\"inherit\", \"inherit\", \"pipe\"],\n\t\t\t\t},\n\t\t\t);\n\t\t\tlet stderr = \"\";\n\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\tstderr += chunk;\n\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t});\n\t\t\tconst exitCode = await waitForExit(child);\n\t\t\tawait proxy?.close();\n\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\tif (kind) {\n\t\t\t\tviolationStore?.add({\n\t\t\t\t\tkind,\n\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn exitCode;\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
@@ -0,0 +1,20 @@
1
+ /** Child-side credential store for sandboxed sessions. */
2
+ import type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai";
3
+ export declare const CREDENTIAL_CHANNEL_SOCKET_ENV = "APEX_CREDENTIAL_PROXY_PATH";
4
+ export declare class SandboxAuthStorage implements CredentialStore {
5
+ private readonly authPath;
6
+ private readonly socketPath;
7
+ private readOnly;
8
+ constructor(options: {
9
+ socketPath: string;
10
+ authPath?: string;
11
+ });
12
+ read(providerId: string, options?: AuthOperationOptions): Promise<Credential | undefined>;
13
+ list(options?: AuthOperationOptions): Promise<readonly CredentialInfo[]>;
14
+ modify(providerId: string, fn: (current: Credential | undefined) => Promise<Credential | undefined>, options?: AuthOperationOptions): Promise<Credential | undefined>;
15
+ delete(providerId: string, options?: AuthOperationOptions): Promise<void>;
16
+ private invalidateReadView;
17
+ private transaction;
18
+ }
19
+ export declare function createSandboxCredentialStore(env?: NodeJS.ProcessEnv): CredentialStore | undefined;
20
+ //# sourceMappingURL=credential-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-client.d.ts","sourceRoot":"","sources":["../../../../src/core/sandbox/rpc/credential-client.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAG1D,OAAO,KAAK,EAAE,oBAAoB,EAAE,UAAU,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAI/G,eAAO,MAAM,6BAA6B,+BAA+B,CAAC;AA4C1E,qBAAa,kBAAmB,YAAW,eAAe;IACzD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAsB;IAEtC,YAAY,OAAO,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,EAI7D;IAEK,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,oBAAoB,GAAG,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC,CAE9F;IAEK,IAAI,CAAC,OAAO,CAAC,EAAE,oBAAoB,GAAG,OAAO,CAAC,SAAS,cAAc,EAAE,CAAC,CAE7E;IAEK,MAAM,CACX,UAAU,EAAE,MAAM,EAClB,EAAE,EAAE,CAAC,OAAO,EAAE,UAAU,GAAG,SAAS,KAAK,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC,EACxE,OAAO,CAAC,EAAE,oBAAoB,GAC5B,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC,CAmBjC;IAEK,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,oBAAoB,GAAG,OAAO,CAAC,IAAI,CAAC,CAG9E;IAED,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,WAAW;CAqGnB;AAED,wBAAgB,4BAA4B,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,eAAe,GAAG,SAAS,CAI9G","sourcesContent":["/** Child-side credential store for sandboxed sessions. */\n\nimport * as net from \"node:net\";\nimport type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore } from \"@earendil-works/pi-ai\";\nimport { getAuthPath } from \"../../../config.ts\";\nimport { ReadOnlyAuthStorage } from \"../../auth-storage.ts\";\n\nexport const CREDENTIAL_CHANNEL_SOCKET_ENV = \"APEX_CREDENTIAL_PROXY_PATH\";\nconst REQUEST_TIMEOUT_MS = 30_000;\nconst MAX_RESPONSE_BYTES = 64 * 1024;\n\ninterface CurrentFrame {\n\treadonly type?: unknown;\n\treadonly credential?: unknown;\n}\n\ninterface ResultFrame {\n\treadonly ok?: unknown;\n\treadonly credential?: unknown;\n\treadonly error?: unknown;\n}\n\nfunction isCredential(value: unknown): value is Credential {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\tconst credential = value as Record<string, unknown>;\n\tif (credential.type === \"api_key\") {\n\t\treturn (\n\t\t\t(credential.key === undefined || typeof credential.key === \"string\") &&\n\t\t\t(credential.env === undefined ||\n\t\t\t\t(typeof credential.env === \"object\" &&\n\t\t\t\t\tcredential.env !== null &&\n\t\t\t\t\t!Array.isArray(credential.env) &&\n\t\t\t\t\tObject.values(credential.env).every((entry) => typeof entry === \"string\")))\n\t\t);\n\t}\n\treturn (\n\t\tcredential.type === \"oauth\" &&\n\t\ttypeof credential.access === \"string\" &&\n\t\ttypeof credential.refresh === \"string\" &&\n\t\ttypeof credential.expires === \"number\" &&\n\t\tNumber.isFinite(credential.expires)\n\t);\n}\n\nfunction parseCurrentCredential(frame: CurrentFrame): Credential | undefined {\n\tif (frame.credential === undefined) return undefined;\n\tif (!isCredential(frame.credential))\n\t\tthrow new Error(\"The credential channel returned an invalid current credential.\");\n\treturn frame.credential;\n}\n\nexport class SandboxAuthStorage implements CredentialStore {\n\tprivate readonly authPath: string;\n\tprivate readonly socketPath: string;\n\tprivate readOnly: ReadOnlyAuthStorage;\n\n\tconstructor(options: { socketPath: string; authPath?: string }) {\n\t\tthis.socketPath = options.socketPath;\n\t\tthis.authPath = options.authPath ?? getAuthPath();\n\t\tthis.readOnly = new ReadOnlyAuthStorage(this.authPath, { resolveCommands: true });\n\t}\n\n\tasync read(providerId: string, options?: AuthOperationOptions): Promise<Credential | undefined> {\n\t\treturn this.readOnly.read(providerId, options);\n\t}\n\n\tasync list(options?: AuthOperationOptions): Promise<readonly CredentialInfo[]> {\n\t\treturn this.readOnly.list(options);\n\t}\n\n\tasync modify(\n\t\tproviderId: string,\n\t\tfn: (current: Credential | undefined) => Promise<Credential | undefined>,\n\t\toptions?: AuthOperationOptions,\n\t): Promise<Credential | undefined> {\n\t\tconst result = await this.transaction(\n\t\t\t{ action: \"modify\", providerId },\n\t\t\tasync (frame, send) => {\n\t\t\t\tif (frame.type !== \"current\") throw new Error(\"The credential channel returned an unexpected response.\");\n\t\t\t\tconst current = parseCurrentCredential(frame);\n\t\t\t\tlet next: Credential | undefined;\n\t\t\t\ttry {\n\t\t\t\t\tnext = await fn(current);\n\t\t\t\t} catch (error) {\n\t\t\t\t\tsend({ action: \"abort\" });\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t\tsend(next === undefined ? { action: \"no_change\" } : { action: \"commit\", credential: next });\n\t\t\t},\n\t\t\toptions,\n\t\t);\n\t\tthis.invalidateReadView();\n\t\treturn result;\n\t}\n\n\tasync delete(providerId: string, options?: AuthOperationOptions): Promise<void> {\n\t\tawait this.transaction({ action: \"delete\", providerId }, undefined, options);\n\t\tthis.invalidateReadView();\n\t}\n\n\tprivate invalidateReadView(): void {\n\t\tthis.readOnly = new ReadOnlyAuthStorage(this.authPath, { resolveCommands: true });\n\t}\n\n\tprivate transaction(\n\t\tinitial: object,\n\t\tonCurrent: ((frame: CurrentFrame, send: (frame: object) => void) => Promise<void>) | undefined,\n\t\toptions?: AuthOperationOptions,\n\t): Promise<Credential | undefined> {\n\t\toptions?.signal?.throwIfAborted();\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tconst client = net.connect(this.socketPath);\n\t\t\tlet settled = false;\n\t\t\tlet buffer = Buffer.alloc(0);\n\t\t\tlet handling = Promise.resolve();\n\t\t\tconst timeout = setTimeout(\n\t\t\t\t() => finish(new Error(\"Timed out waiting for the credential channel to answer.\")),\n\t\t\t\tREQUEST_TIMEOUT_MS,\n\t\t\t);\n\n\t\t\tconst finish = (error: Error | undefined, result?: Credential): void => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\tclearTimeout(timeout);\n\t\t\t\toptions?.signal?.removeEventListener(\"abort\", onAbort);\n\t\t\t\tclient.destroy();\n\t\t\t\tif (error) reject(error);\n\t\t\t\telse resolve(result);\n\t\t\t};\n\t\t\tconst onAbort = (): void => finish(new Error(\"Operation aborted.\"));\n\t\t\tconst send = (frame: object): void => {\n\t\t\t\tif (!client.destroyed) client.write(`${JSON.stringify(frame)}\\n`);\n\t\t\t};\n\t\t\tconst handleFrame = async (frame: unknown): Promise<void> => {\n\t\t\t\tif (typeof frame !== \"object\" || frame === null || Array.isArray(frame)) {\n\t\t\t\t\tfinish(new Error(\"The credential channel returned a malformed response.\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst response = frame as CurrentFrame & ResultFrame;\n\t\t\t\tif (response.type === \"current\") {\n\t\t\t\t\tif (!onCurrent) throw new Error(\"The credential channel returned an unexpected current value.\");\n\t\t\t\t\tawait onCurrent(response, send);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (response.ok === true) {\n\t\t\t\t\tif (response.credential !== undefined && !isCredential(response.credential)) {\n\t\t\t\t\t\tthrow new Error(\"The credential channel returned an invalid credential.\");\n\t\t\t\t\t}\n\t\t\t\t\tfinish(undefined, response.credential as Credential | undefined);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tfinish(\n\t\t\t\t\tnew Error(\n\t\t\t\t\t\ttypeof response.error === \"string\" ? response.error : \"The credential channel refused the mutation.\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t};\n\n\t\t\toptions?.signal?.addEventListener(\"abort\", onAbort, { once: true });\n\t\t\tclient.on(\"connect\", () => send(initial));\n\t\t\tclient.on(\"data\", (chunk: Buffer) => {\n\t\t\t\tbuffer = Buffer.concat([buffer, chunk]);\n\t\t\t\tif (buffer.length > MAX_RESPONSE_BYTES && buffer.indexOf(0x0a) < 0) {\n\t\t\t\t\tfinish(new Error(\"The credential channel response is too large.\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tlet newline = buffer.indexOf(0x0a);\n\t\t\t\twhile (newline >= 0) {\n\t\t\t\t\tif (newline > MAX_RESPONSE_BYTES) {\n\t\t\t\t\t\tfinish(new Error(\"The credential channel response is too large.\"));\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tconst raw = buffer.subarray(0, newline);\n\t\t\t\t\tbuffer = buffer.subarray(newline + 1);\n\t\t\t\t\tlet frame: unknown;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tframe = JSON.parse(raw.toString(\"utf8\")) as unknown;\n\t\t\t\t\t} catch {\n\t\t\t\t\t\tfinish(new Error(\"The credential channel returned a malformed response.\"));\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\thandling = handling\n\t\t\t\t\t\t.then(() => handleFrame(frame))\n\t\t\t\t\t\t.catch((error: unknown) => {\n\t\t\t\t\t\t\tfinish(error instanceof Error ? error : new Error(String(error)));\n\t\t\t\t\t\t});\n\t\t\t\t\tnewline = buffer.indexOf(0x0a);\n\t\t\t\t}\n\t\t\t\tif (buffer.length > MAX_RESPONSE_BYTES) {\n\t\t\t\t\tfinish(new Error(\"The credential channel response is too large.\"));\n\t\t\t\t}\n\t\t\t});\n\t\t\tclient.on(\"error\", (error: NodeJS.ErrnoException) => {\n\t\t\t\tif (error.code === \"EACCES\" || error.code === \"ENOENT\" || error.code === \"ECONNREFUSED\") {\n\t\t\t\t\tfinish(\n\t\t\t\t\t\tnew Error(\n\t\t\t\t\t\t\t`The sandbox credential channel is unavailable (${error.code}); this session cannot modify credentials.`,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tfinish(new Error(`The sandbox credential channel failed: ${error.message}`));\n\t\t\t});\n\t\t});\n\t}\n}\n\nexport function createSandboxCredentialStore(env: NodeJS.ProcessEnv = process.env): CredentialStore | undefined {\n\tconst socketPath = env[CREDENTIAL_CHANNEL_SOCKET_ENV];\n\tif (!socketPath) return undefined;\n\treturn new SandboxAuthStorage({ socketPath });\n}\n"]}