@neuralis/brain-core 0.3.1 → 0.4.0

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 (371) hide show
  1. package/README.md +88 -1417
  2. package/app/README.md +4 -4
  3. package/app/cards/FilesystemCardShell.tsx +42 -4
  4. package/app/cards/FilesystemChangeCard.tsx +34 -11
  5. package/app/cards/README.md +6 -5
  6. package/app/cards/diffUtils.ts +99 -1
  7. package/app/filesystem/FilesystemHost.tsx +24 -2
  8. package/app/filesystem/README.md +1 -1
  9. package/app/filesystem/api/README.md +3 -3
  10. package/app/filesystem/api/index.ts +38 -14
  11. package/app/filesystem/api/projectPackages.ts +13 -102
  12. package/app/filesystem/domain/types.ts +30 -10
  13. package/app/filesystem/hooks/README.md +4 -4
  14. package/app/filesystem/hooks/useDiscoverableSources.ts +19 -30
  15. package/app/filesystem/hooks/useFilesystemSSE.ts +42 -2
  16. package/app/filesystem/host/README.md +6 -4
  17. package/app/filesystem/host/buildFilesystemHostValue.ts +28 -2
  18. package/app/filesystem/host/installFilesystemHostComponents.tsx +1 -1
  19. package/app/filesystem/lib/sourceKindForm.ts +210 -27
  20. package/app/filesystem/lib/treeVisualState.tsx +98 -4
  21. package/app/filesystem/store/README.md +1 -0
  22. package/app/filesystem/store/filesystemStore.ts +99 -8
  23. package/app/filesystem/store/gitSlice.ts +5 -0
  24. package/app/filesystem/store/helpers.ts +17 -0
  25. package/app/filesystem/store/listSlice.ts +8 -0
  26. package/app/filesystem/ui/CodeEditor.tsx +18 -3
  27. package/app/filesystem/ui/DiscoverDock.tsx +5 -1
  28. package/app/filesystem/ui/FileEditor.tsx +15 -10
  29. package/app/filesystem/ui/FileTree.tsx +75 -24
  30. package/app/filesystem/ui/FilesystemWidget.tsx +10 -2
  31. package/app/filesystem/ui/FolderPicker.tsx +251 -0
  32. package/app/filesystem/ui/GitControlPanel.tsx +7 -2
  33. package/app/filesystem/ui/PackagesPanel.tsx +52 -374
  34. package/app/filesystem/ui/PendingChangesPanel.tsx +1 -1
  35. package/app/filesystem/ui/PolicyEditor.tsx +109 -24
  36. package/app/filesystem/ui/README.md +30 -23
  37. package/app/filesystem/ui/RawMediaPreview.tsx +58 -0
  38. package/app/filesystem/ui/SourceBranchHeader.tsx +4 -4
  39. package/app/filesystem/ui/SourcesPanel.tsx +502 -342
  40. package/app/filesystem/ui/Toolbar.tsx +1 -2
  41. package/app/filesystem/ui/VectorHealthPanel.tsx +2 -2
  42. package/app/shared/filesystem.png +0 -0
  43. package/dist/app/host.js +58 -46
  44. package/dist/scripts/sourceRootPairV1.d.ts +47 -0
  45. package/dist/scripts/sourceRootPairV1.d.ts.map +1 -0
  46. package/dist/scripts/sourceRootPairV1.js +147 -0
  47. package/dist/scripts/sourceRootPairV1.js.map +1 -0
  48. package/dist/scripts/sourceToFolderV1.d.ts +56 -0
  49. package/dist/scripts/sourceToFolderV1.d.ts.map +1 -0
  50. package/dist/scripts/sourceToFolderV1.js +152 -0
  51. package/dist/scripts/sourceToFolderV1.js.map +1 -0
  52. package/dist/scripts/sourceToHostV1.d.ts +56 -0
  53. package/dist/scripts/sourceToHostV1.d.ts.map +1 -0
  54. package/dist/scripts/sourceToHostV1.js +132 -0
  55. package/dist/scripts/sourceToHostV1.js.map +1 -0
  56. package/dist/src/channels/filesystem.d.ts +1 -1
  57. package/dist/src/channels/filesystem.js +1 -1
  58. package/dist/src/connectors/AppSourceConfigStore.d.ts +17 -3
  59. package/dist/src/connectors/AppSourceConfigStore.d.ts.map +1 -1
  60. package/dist/src/connectors/AppSourceConfigStore.js +46 -6
  61. package/dist/src/connectors/AppSourceConfigStore.js.map +1 -1
  62. package/dist/src/connectors/HostConnector.d.ts +201 -0
  63. package/dist/src/connectors/HostConnector.d.ts.map +1 -0
  64. package/dist/src/connectors/HostConnector.js +903 -0
  65. package/dist/src/connectors/HostConnector.js.map +1 -0
  66. package/dist/src/connectors/inProcessTransport.d.ts +131 -0
  67. package/dist/src/connectors/inProcessTransport.d.ts.map +1 -0
  68. package/dist/src/connectors/inProcessTransport.js +447 -0
  69. package/dist/src/connectors/inProcessTransport.js.map +1 -0
  70. package/dist/src/domain/access/callerPermissions.d.ts +29 -0
  71. package/dist/src/domain/access/callerPermissions.d.ts.map +1 -1
  72. package/dist/src/domain/access/callerPermissions.js +85 -5
  73. package/dist/src/domain/access/callerPermissions.js.map +1 -1
  74. package/dist/src/domain/access/nativeLocalFence.d.ts +65 -0
  75. package/dist/src/domain/access/nativeLocalFence.d.ts.map +1 -0
  76. package/dist/src/domain/access/nativeLocalFence.js +171 -0
  77. package/dist/src/domain/access/nativeLocalFence.js.map +1 -0
  78. package/dist/src/domain/access/uriPermission.js +3 -3
  79. package/dist/src/domain/access/uriPermission.js.map +1 -1
  80. package/dist/src/domain/byteRange.d.ts +15 -0
  81. package/dist/src/domain/byteRange.d.ts.map +1 -0
  82. package/dist/src/domain/byteRange.js +30 -0
  83. package/dist/src/domain/byteRange.js.map +1 -0
  84. package/dist/src/domain/defaultPermissions.d.ts.map +1 -1
  85. package/dist/src/domain/defaultPermissions.js +29 -0
  86. package/dist/src/domain/defaultPermissions.js.map +1 -1
  87. package/dist/src/domain/mime.d.ts +11 -19
  88. package/dist/src/domain/mime.d.ts.map +1 -1
  89. package/dist/src/domain/mime.js +13 -102
  90. package/dist/src/domain/mime.js.map +1 -1
  91. package/dist/src/domain/paths.d.ts.map +1 -1
  92. package/dist/src/domain/paths.js +5 -3
  93. package/dist/src/domain/paths.js.map +1 -1
  94. package/dist/src/domain/scopeAccessCtx.d.ts +2 -2
  95. package/dist/src/domain/scopeAccessCtx.js +2 -2
  96. package/dist/src/domain/sourceConfig.d.ts +52 -19
  97. package/dist/src/domain/sourceConfig.d.ts.map +1 -1
  98. package/dist/src/domain/sourceConfig.js +103 -19
  99. package/dist/src/domain/sourceConfig.js.map +1 -1
  100. package/dist/src/domain/sourcePackages.d.ts +8 -8
  101. package/dist/src/domain/sourcePackages.d.ts.map +1 -1
  102. package/dist/src/domain/sourceRootPair.d.ts +88 -0
  103. package/dist/src/domain/sourceRootPair.d.ts.map +1 -0
  104. package/dist/src/domain/sourceRootPair.js +108 -0
  105. package/dist/src/domain/sourceRootPair.js.map +1 -0
  106. package/dist/src/domain/syncTypes.d.ts +8 -1
  107. package/dist/src/domain/syncTypes.d.ts.map +1 -1
  108. package/dist/src/domain/syncTypes.js +1 -0
  109. package/dist/src/domain/syncTypes.js.map +1 -1
  110. package/dist/src/domain/types.d.ts +32 -2
  111. package/dist/src/domain/types.d.ts.map +1 -1
  112. package/dist/src/domain/uri.d.ts.map +1 -1
  113. package/dist/src/domain/uri.js +3 -2
  114. package/dist/src/domain/uri.js.map +1 -1
  115. package/dist/src/embeddings/clients/GeminiEmbedder.d.ts.map +1 -1
  116. package/dist/src/embeddings/clients/GeminiEmbedder.js +2 -5
  117. package/dist/src/embeddings/clients/GeminiEmbedder.js.map +1 -1
  118. package/dist/src/embeddings/clients/OpenAIEmbedder.d.ts.map +1 -1
  119. package/dist/src/embeddings/clients/OpenAIEmbedder.js +2 -3
  120. package/dist/src/embeddings/clients/OpenAIEmbedder.js.map +1 -1
  121. package/dist/src/embeddings/clients/QwenEmbedder.d.ts +2 -0
  122. package/dist/src/embeddings/clients/QwenEmbedder.d.ts.map +1 -1
  123. package/dist/src/embeddings/clients/QwenEmbedder.js +10 -2
  124. package/dist/src/embeddings/clients/QwenEmbedder.js.map +1 -1
  125. package/dist/src/embeddings/clients/VoyageEmbedder.d.ts +2 -0
  126. package/dist/src/embeddings/clients/VoyageEmbedder.d.ts.map +1 -1
  127. package/dist/src/embeddings/clients/VoyageEmbedder.js +8 -1
  128. package/dist/src/embeddings/clients/VoyageEmbedder.js.map +1 -1
  129. package/dist/src/embeddings/factory.d.ts +5 -3
  130. package/dist/src/embeddings/factory.d.ts.map +1 -1
  131. package/dist/src/embeddings/factory.js +7 -3
  132. package/dist/src/embeddings/factory.js.map +1 -1
  133. package/dist/src/embeddings/models.d.ts +1 -1
  134. package/dist/src/embeddings/models.d.ts.map +1 -1
  135. package/dist/src/embeddings/retry.js +1 -1
  136. package/dist/src/embeddings/retry.js.map +1 -1
  137. package/dist/src/embeddings/types.d.ts +6 -0
  138. package/dist/src/embeddings/types.d.ts.map +1 -1
  139. package/dist/src/embeddings/types.js +11 -0
  140. package/dist/src/embeddings/types.js.map +1 -1
  141. package/dist/src/events/pending.d.ts +1 -1
  142. package/dist/src/events/pending.js +1 -1
  143. package/dist/src/git/GitRunner.d.ts +110 -30
  144. package/dist/src/git/GitRunner.d.ts.map +1 -1
  145. package/dist/src/git/GitRunner.js +511 -199
  146. package/dist/src/git/GitRunner.js.map +1 -1
  147. package/dist/src/git/GitWatchRegistry.d.ts +41 -35
  148. package/dist/src/git/GitWatchRegistry.d.ts.map +1 -1
  149. package/dist/src/git/GitWatchRegistry.js +61 -79
  150. package/dist/src/git/GitWatchRegistry.js.map +1 -1
  151. package/dist/src/git/gitPlace.d.ts +61 -0
  152. package/dist/src/git/gitPlace.d.ts.map +1 -0
  153. package/dist/src/git/gitPlace.js +74 -0
  154. package/dist/src/git/gitPlace.js.map +1 -0
  155. package/dist/src/git/sourceGitControl.d.ts +33 -21
  156. package/dist/src/git/sourceGitControl.d.ts.map +1 -1
  157. package/dist/src/git/sourceGitControl.js +211 -95
  158. package/dist/src/git/sourceGitControl.js.map +1 -1
  159. package/dist/src/git/sourceGitStatus.js +1 -1
  160. package/dist/src/git/sourceGitStatus.js.map +1 -1
  161. package/dist/src/git/untrackedStats.js +1 -1
  162. package/dist/src/git/untrackedStats.js.map +1 -1
  163. package/dist/src/index.d.ts +2 -1
  164. package/dist/src/index.d.ts.map +1 -1
  165. package/dist/src/index.js +2 -1
  166. package/dist/src/index.js.map +1 -1
  167. package/dist/src/lifecycle.d.ts +9 -12
  168. package/dist/src/lifecycle.d.ts.map +1 -1
  169. package/dist/src/lifecycle.js +2 -1
  170. package/dist/src/lifecycle.js.map +1 -1
  171. package/dist/src/routes/approve.d.ts +1 -1
  172. package/dist/src/routes/approve.js +1 -1
  173. package/dist/src/routes/events.d.ts +1 -1
  174. package/dist/src/routes/events.js +1 -1
  175. package/dist/src/routes/git.d.ts +7 -7
  176. package/dist/src/routes/git.d.ts.map +1 -1
  177. package/dist/src/routes/git.js +201 -72
  178. package/dist/src/routes/git.js.map +1 -1
  179. package/dist/src/routes/health.d.ts +6 -6
  180. package/dist/src/routes/health.js +12 -12
  181. package/dist/src/routes/helpers/sourceNotConfigured.d.ts +1 -1
  182. package/dist/src/routes/helpers/sourceNotConfigured.js +1 -1
  183. package/dist/src/routes/helpers/uriGuard.d.ts +1 -1
  184. package/dist/src/routes/helpers/uriGuard.d.ts.map +1 -1
  185. package/dist/src/routes/helpers/uriGuard.js +3 -2
  186. package/dist/src/routes/helpers/uriGuard.js.map +1 -1
  187. package/dist/src/routes/helpers.d.ts +11 -4
  188. package/dist/src/routes/helpers.d.ts.map +1 -1
  189. package/dist/src/routes/helpers.js +13 -6
  190. package/dist/src/routes/helpers.js.map +1 -1
  191. package/dist/src/routes/list.d.ts +1 -1
  192. package/dist/src/routes/list.d.ts.map +1 -1
  193. package/dist/src/routes/list.js +3 -4
  194. package/dist/src/routes/list.js.map +1 -1
  195. package/dist/src/routes/pending.d.ts +4 -4
  196. package/dist/src/routes/pending.js +9 -9
  197. package/dist/src/routes/policies.d.ts +10 -8
  198. package/dist/src/routes/policies.d.ts.map +1 -1
  199. package/dist/src/routes/policies.js +31 -25
  200. package/dist/src/routes/policies.js.map +1 -1
  201. package/dist/src/routes/raw.d.ts +1 -1
  202. package/dist/src/routes/raw.d.ts.map +1 -1
  203. package/dist/src/routes/raw.js +66 -12
  204. package/dist/src/routes/raw.js.map +1 -1
  205. package/dist/src/routes/read.d.ts +1 -1
  206. package/dist/src/routes/read.js +1 -1
  207. package/dist/src/routes/search.d.ts +1 -1
  208. package/dist/src/routes/search.js +1 -1
  209. package/dist/src/routes/sources.d.ts +63 -6
  210. package/dist/src/routes/sources.d.ts.map +1 -1
  211. package/dist/src/routes/sources.js +494 -382
  212. package/dist/src/routes/sources.js.map +1 -1
  213. package/dist/src/routes/upload.d.ts +1 -1
  214. package/dist/src/routes/upload.js +1 -1
  215. package/dist/src/routes/write.d.ts +2 -2
  216. package/dist/src/routes/write.js +2 -2
  217. package/dist/src/services/BridgeService.d.ts +21 -35
  218. package/dist/src/services/BridgeService.d.ts.map +1 -1
  219. package/dist/src/services/BridgeService.js +42 -88
  220. package/dist/src/services/BridgeService.js.map +1 -1
  221. package/dist/src/services/FilesystemService.d.ts +4 -3
  222. package/dist/src/services/FilesystemService.d.ts.map +1 -1
  223. package/dist/src/services/FilesystemService.js +112 -34
  224. package/dist/src/services/FilesystemService.js.map +1 -1
  225. package/dist/src/services/FilesystemUiService.d.ts +4 -1
  226. package/dist/src/services/FilesystemUiService.d.ts.map +1 -1
  227. package/dist/src/services/FilesystemUiService.js +66 -25
  228. package/dist/src/services/FilesystemUiService.js.map +1 -1
  229. package/dist/src/services/SyncScheduler.d.ts +12 -4
  230. package/dist/src/services/SyncScheduler.d.ts.map +1 -1
  231. package/dist/src/services/SyncScheduler.js +37 -8
  232. package/dist/src/services/SyncScheduler.js.map +1 -1
  233. package/dist/src/services/baselineRepair.d.ts +13 -3
  234. package/dist/src/services/baselineRepair.d.ts.map +1 -1
  235. package/dist/src/services/baselineRepair.js +13 -3
  236. package/dist/src/services/baselineRepair.js.map +1 -1
  237. package/dist/src/services/declaredSources.d.ts +132 -43
  238. package/dist/src/services/declaredSources.d.ts.map +1 -1
  239. package/dist/src/services/declaredSources.js +118 -45
  240. package/dist/src/services/declaredSources.js.map +1 -1
  241. package/dist/src/store/dataFormats.d.ts +17 -2
  242. package/dist/src/store/dataFormats.d.ts.map +1 -1
  243. package/dist/src/store/dataFormats.js +18 -3
  244. package/dist/src/store/dataFormats.js.map +1 -1
  245. package/dist/src/sync/DeltaSyncRunner.d.ts.map +1 -1
  246. package/dist/src/sync/DeltaSyncRunner.js +144 -4
  247. package/dist/src/sync/DeltaSyncRunner.js.map +1 -1
  248. package/dist/src/sync/SyncJobQueue.d.ts +7 -2
  249. package/dist/src/sync/SyncJobQueue.d.ts.map +1 -1
  250. package/dist/src/sync/SyncJobQueue.js +16 -10
  251. package/dist/src/sync/SyncJobQueue.js.map +1 -1
  252. package/dist/src/sync/SyncWatchService.d.ts +21 -4
  253. package/dist/src/sync/SyncWatchService.d.ts.map +1 -1
  254. package/dist/src/sync/SyncWatchService.js +115 -10
  255. package/dist/src/sync/SyncWatchService.js.map +1 -1
  256. package/dist/src/sync/categoryInference.d.ts +1 -1
  257. package/dist/src/sync/categoryInference.js +1 -1
  258. package/dist/src/tools/execCtx.d.ts +2 -2
  259. package/dist/src/tools/execCtx.js +2 -2
  260. package/dist/src/tools/fs_read.d.ts.map +1 -1
  261. package/dist/src/tools/fs_read.js +5 -0
  262. package/dist/src/tools/fs_read.js.map +1 -1
  263. package/dist/src/usecases/admitSourceRoot.d.ts +65 -0
  264. package/dist/src/usecases/admitSourceRoot.d.ts.map +1 -0
  265. package/dist/src/usecases/admitSourceRoot.js +85 -0
  266. package/dist/src/usecases/admitSourceRoot.js.map +1 -0
  267. package/dist/src/usecases/browseSourceFolders.d.ts +85 -0
  268. package/dist/src/usecases/browseSourceFolders.d.ts.map +1 -0
  269. package/dist/src/usecases/browseSourceFolders.js +210 -0
  270. package/dist/src/usecases/browseSourceFolders.js.map +1 -0
  271. package/dist/src/usecases/createDeclaredSources.d.ts +13 -2
  272. package/dist/src/usecases/createDeclaredSources.d.ts.map +1 -1
  273. package/dist/src/usecases/createDeclaredSources.js +39 -0
  274. package/dist/src/usecases/createDeclaredSources.js.map +1 -1
  275. package/dist/src/usecases/embeddingBacklog.js +1 -1
  276. package/dist/src/usecases/embeddingBacklog.js.map +1 -1
  277. package/dist/src/usecases/list.d.ts.map +1 -1
  278. package/dist/src/usecases/list.js +83 -26
  279. package/dist/src/usecases/list.js.map +1 -1
  280. package/dist/src/usecases/listAttachableDevices.d.ts +13 -0
  281. package/dist/src/usecases/listAttachableDevices.d.ts.map +1 -0
  282. package/dist/src/usecases/listAttachableDevices.js +10 -0
  283. package/dist/src/usecases/listAttachableDevices.js.map +1 -0
  284. package/dist/src/usecases/listSourcePackages.d.ts.map +1 -1
  285. package/dist/src/usecases/listSourcePackages.js +2 -5
  286. package/dist/src/usecases/listSourcePackages.js.map +1 -1
  287. package/dist/src/usecases/purgeExcludedPoints.d.ts.map +1 -1
  288. package/dist/src/usecases/purgeExcludedPoints.js +3 -2
  289. package/dist/src/usecases/purgeExcludedPoints.js.map +1 -1
  290. package/dist/src/usecases/readFiles.d.ts.map +1 -1
  291. package/dist/src/usecases/readFiles.js +99 -1
  292. package/dist/src/usecases/readFiles.js.map +1 -1
  293. package/dist/src/usecases/reconcileSourcePackageRoots.d.ts +1 -1
  294. package/dist/src/usecases/reconcileSourcePackageRoots.d.ts.map +1 -1
  295. package/dist/src/usecases/reconcileSourcePackageRoots.js +2 -1
  296. package/dist/src/usecases/reconcileSourcePackageRoots.js.map +1 -1
  297. package/dist/src/usecases/revokePrincipal.d.ts +7 -5
  298. package/dist/src/usecases/revokePrincipal.d.ts.map +1 -1
  299. package/dist/src/usecases/revokePrincipal.js +29 -7
  300. package/dist/src/usecases/revokePrincipal.js.map +1 -1
  301. package/dist/src/usecases/scheduleSourceSync.d.ts +7 -0
  302. package/dist/src/usecases/scheduleSourceSync.d.ts.map +1 -1
  303. package/dist/src/usecases/scheduleSourceSync.js +6 -5
  304. package/dist/src/usecases/scheduleSourceSync.js.map +1 -1
  305. package/dist/src/usecases/syncPointWrites.d.ts +2 -0
  306. package/dist/src/usecases/syncPointWrites.d.ts.map +1 -1
  307. package/dist/src/usecases/syncPointWrites.js +5 -1
  308. package/dist/src/usecases/syncPointWrites.js.map +1 -1
  309. package/dist/src/usecases/triggerSourceSync.d.ts +46 -0
  310. package/dist/src/usecases/triggerSourceSync.d.ts.map +1 -0
  311. package/dist/src/usecases/triggerSourceSync.js +75 -0
  312. package/dist/src/usecases/triggerSourceSync.js.map +1 -0
  313. package/dist/src/usecases/uploadBinary.d.ts +2 -1
  314. package/dist/src/usecases/uploadBinary.d.ts.map +1 -1
  315. package/dist/src/usecases/uploadBinary.js +2 -2
  316. package/dist/src/usecases/uploadBinary.js.map +1 -1
  317. package/dist/src/usecases/vector/endpointGuard.d.ts +1 -1
  318. package/dist/src/usecases/vector/endpointGuard.js +1 -1
  319. package/dist/src/usecases/vectorMaintenance.js +1 -1
  320. package/dist/src/usecases/vectorMaintenance.js.map +1 -1
  321. package/dist/src/usecases/write/content.js +1 -1
  322. package/dist/src/vector/qdrantClient.d.ts +1 -1
  323. package/dist/src/vector/qdrantClient.js +1 -1
  324. package/dist/src/wiring.d.ts +3 -0
  325. package/dist/src/wiring.d.ts.map +1 -1
  326. package/dist/src/wiring.js +72 -6
  327. package/dist/src/wiring.js.map +1 -1
  328. package/dist/tools/fs_list.json +1 -1
  329. package/dist/tools/fs_read.json +1 -1
  330. package/dist/tools/fs_search.json +1 -1
  331. package/dist/tools/fs_write.json +1 -1
  332. package/instructions/brain-model.md +1 -1
  333. package/package.json +212 -114
  334. package/rules/brain-rules.md +2 -2
  335. package/skills/brain-start/SKILL.md +1 -1
  336. package/skills/connect-business-data/SKILL.md +3 -3
  337. package/skills/create-package/SKILL.md +26 -26
  338. package/skills/create-package/references/config-and-credentials.md +16 -13
  339. package/skills/create-package/references/connectors-and-uri-policies.md +39 -25
  340. package/skills/create-package/references/manifest-and-discovery.md +12 -12
  341. package/skills/create-package/references/runtime-and-build.md +19 -19
  342. package/skills/create-package/references/skills-and-context.md +36 -20
  343. package/skills/create-package/references/surfaces-and-design.md +16 -16
  344. package/skills/create-package/references/team-and-workflows.md +2 -2
  345. package/skills/create-package/references/test-and-ship.md +21 -21
  346. package/skills/create-package/references/tools-and-schemas.md +5 -5
  347. package/skills/create-package/scripts/{check-package.sh → check-package.mjs} +79 -65
  348. package/skills/create-package/scripts/{verify-loaded.sh → verify-loaded.mjs} +156 -162
  349. package/skills/git/SKILL.md +53 -51
  350. package/skills/manage-files/SKILL.md +22 -25
  351. package/skills/manage-sources/SKILL.md +128 -87
  352. package/skills/manage-uri-policy/SKILL.md +18 -15
  353. package/skills/organize/SKILL.md +1 -1
  354. package/skills/sync/SKILL.md +3 -3
  355. package/tools/fs_list.json +1 -1
  356. package/tools/fs_read.json +1 -1
  357. package/tools/fs_search.json +1 -1
  358. package/tools/fs_write.json +1 -1
  359. package/workflows/sources-digest.json +1 -1
  360. package/dist/src/connectors/LocalDiskConnector.d.ts +0 -171
  361. package/dist/src/connectors/LocalDiskConnector.d.ts.map +0 -1
  362. package/dist/src/connectors/LocalDiskConnector.js +0 -1233
  363. package/dist/src/connectors/LocalDiskConnector.js.map +0 -1
  364. package/docs/drive-overview.md +0 -59
  365. package/skills/manage-files/scripts/copy.sh +0 -7
  366. package/skills/manage-files/scripts/create.sh +0 -7
  367. package/skills/manage-files/scripts/mkdir.sh +0 -7
  368. package/skills/manage-files/scripts/modify.sh +0 -7
  369. package/skills/manage-files/scripts/upload.sh +0 -16
  370. package/skills/manage-files/scripts/write.sh +0 -29
  371. package/skills/manage-sources/scripts/reembed.sh +0 -23
@@ -6,7 +6,7 @@
6
6
  * GET /sources/:slug — get single source config
7
7
  * GET /sources/kinds — list registered source kinds (+ trust, package attribution)
8
8
  * GET /sources/kinds/:kind/schema — JSON Schema for admin source form
9
- * GET /sources/discoverable — well-known unattached source roots (W3E; drive.mount)
9
+ * GET /sources/discoverable — well-known unattached source roots (W3E; sources.mount)
10
10
  * POST /sources — create source
11
11
  * POST /sources/preflight — validate/authorize create without writing
12
12
  * PATCH /sources — update source
@@ -18,9 +18,13 @@
18
18
  * POST /sources/reset — reset sync state (cancels active job)
19
19
  * GET /sources/sync-status — job status (?source= single, else all visible)
20
20
  * POST /sources/enabled — light enable/disable toggle (W3K)
21
- * GET /sources/browse — browse filesystem directories (drive.mount;
22
- * protected zones + the fs root need
23
- * drive.mount.privileged)
21
+ * GET /sources/browse — one level of folders: on this server
22
+ * (sources.mount; protected zones + the
23
+ * fs root need sources.privileged; in
24
+ * Docker under /mounts), or with
25
+ * ?device= on the caller's OWN device
26
+ * (device.own; empty path = the
27
+ * folders it allows this host)
24
28
  * GET /sources/health — get sources health
25
29
  *
26
30
  * Sync-shaped POSTs return 202 + a SyncQueueJobSnapshot: the run executes on
@@ -28,34 +32,40 @@
28
32
  * froze the host on repo-sized sources).
29
33
  */
30
34
  import { existsSync } from 'node:fs';
31
- import { isAbsolute, resolve as resolvePath } from 'node:path';
32
- import { canAccessScope, isHostPlaneKind, isSourceScope } from '@neuralis/package-system/contracts';
33
- import { hasFeature, BUILTIN_ROLE_PRIORITY } from '@neuralis/package-system/access';
35
+ import { dirname as dirnameOf, isAbsolute, resolve as resolvePath } from 'node:path';
36
+ import { HOST_SOURCE_KIND, canAccessScope, isConnectorPlaneExecKind, isSourceScope, sourcePlane } from '@neuralis/package-system/contracts';
37
+ // `isOwnerOrAdminEffective` gates the code-LOAD surface (`packageFolders`): the
38
+ // ordinal, never a role name — the Sources panel hides the field with the SAME call.
39
+ import { hasFeature, isOwnerOrAdminEffective } from '@neuralis/package-system/access';
34
40
  import { assertImmutablePolicyFloorsPreserved, CallerPolicyFloorError, containsPolicyFloorMarker, ImmutablePolicyFloorError, } from '@neuralis/package-system/policies';
35
- import { protectedRootPaths, seedExcludedMachineWrittenGlobs } from '@neuralis/package-system/paths';
36
- import { resolveLocalRoot, LocalDiskConnector } from '../connectors/LocalDiskConnector.js';
41
+ import { hostPathFlavour, isFilesystemRoot, isPathInside, protectedRootPaths, seedExcludedMachineWrittenGlobs } from '@neuralis/package-system/paths';
42
+ import { NEURALIS_TREE_ANCHOR, resolveLocalRoot, resolvedLocalRootOrNull } from '../connectors/inProcessTransport.js';
43
+ import { SOURCE_ANCHOR_UNRESOLVABLE, admitSourceRoot, unlessAnchorUnresolvable } from '../usecases/admitSourceRoot.js';
44
+ import { HostConnector } from '../connectors/HostConnector.js';
45
+ import { listAttachableDevices } from '../usecases/listAttachableDevices.js';
46
+ import { browseDeviceFolders, browseServerFolders } from '../usecases/browseSourceFolders.js';
37
47
  import { filterVisibleRepoNames, getSourceGitSummary, resolveGitCanWrite, resolveGitCanExec } from '../git/sourceGitControl.js';
48
+ import { probePlaceOf } from '../git/gitPlace.js';
38
49
  import { matchDeclaredSource, isOwnDataZoneDeclaration } from '../services/declaredSources.js';
39
50
  import { platformRegistries } from '../wiring.js';
40
- import { resolveSourceDescription } from '../domain/sourceConfig.js';
51
+ import { resolveSourceDescription, validatePackageFolders, sourceScopeOrigin } from '../domain/sourceConfig.js';
41
52
  import { extractCtx, ensureAccess, getServices, toPublicSourceConfig, toScopeAccessCtx, isPermissions, normalizePermissions, isSyncConfig } from './helpers.js';
42
53
  import { EmbeddingScopeLimitError } from '../sync/SyncJobQueue.js';
43
54
  import { canReadUri } from '../domain/access/uriReadable.js';
55
+ import { NATIVE_LOCAL_EDIT_REFUSAL, NATIVE_LOCAL_REFUSAL, attachableNatively, fencedEditWidens, nativeLocalFenceApplies, nativeLocalPlacement, nativeLocalRootRefused } from '../domain/access/nativeLocalFence.js';
44
56
  import { listScopeDocumentUris, estimateScopedReembed } from '../usecases/vector/estimate.js';
45
57
  import { embeddingEnabled, resolveActiveEmbeddingShape } from '../config/env.js';
46
58
  import { parsePurgeSelector, purgeSourceSelection } from '../usecases/purgeExcludedPoints.js';
47
59
  export const pattern = 'sources/*';
48
- export const feature = 'drive.read';
60
+ export const feature = 'files.read';
49
61
  // The module gate is the read tier (listing a source is member-visible); every
50
62
  // mutating / enumerating arm escalates in-handler through `hasRouteFeature`.
51
- // `drive.mount.host` never appears as a literal at a gate — the host-plane arm
52
- // folds it through `hostPlaneRequiredFeatures()` and enforces each returned id
53
- // in a loop — so the drift guard carries it as a named exemption row.
63
+ // A device-plane (paired-device) source is attached by its owner under
64
+ // `device.own`, which agent-core's device gate answers — not a gate here.
54
65
  export const escalations = [
55
- 'drive.mount',
56
- 'drive.mount.privileged',
57
- 'drive.mount.host',
58
- 'drive.sync',
66
+ 'sources.mount',
67
+ 'sources.privileged',
68
+ 'sources.sync',
59
69
  'platform.vector.maintain',
60
70
  ];
61
71
  export const GET = async (req, raw) => {
@@ -75,6 +85,11 @@ export const GET = async (req, raw) => {
75
85
  if (sub === 'sync-status') {
76
86
  return handleSyncStatus(req, state);
77
87
  }
88
+ if (sub === 'devices') {
89
+ const ctx = extractCtx(req);
90
+ await ensureAccess(ctx, state);
91
+ return { status: 200, body: await listAttachableDevices(state.ctx.packages, ctx.userId) };
92
+ }
78
93
  if (sub === 'health') {
79
94
  const ctx = extractCtx(req);
80
95
  await ensureAccess(ctx, state);
@@ -102,17 +117,18 @@ export const GET = async (req, raw) => {
102
117
  // against the declared sources (unspoofable slug+kind+resolved-root) so the
103
118
  // Sources panel can group rows by their owning package; user mounts → null.
104
119
  const infra = await state.infraFactory.getInfra(ctx.projectId);
105
- const matchCtx = { projectRoot: infra?.projectRoot ?? '', appRoot: state.ctx.platform.runtimeStack().appRoot };
120
+ const matchCtx = { projectRoot: infra?.projectRoot ?? '', appRoot: state.ctx.platform.runtimeStack().appRoot, isDocker: state.ctx.platform.runtimeStack().isDocker };
106
121
  const declsByPkg = state.ctx.platform.packageSourceDeclarations();
107
122
  const dataSyncExclude = seedExcludedMachineWrittenGlobs(state.ctx.platform.machineWrittenPaths());
108
123
  // The projection reads every row's config from the listing already in hand,
109
124
  // so the per-row root-read check costs no config fetch.
110
- const protectedRoots = buildProtectedRoots(state.ctx.platform.runtimeStack());
125
+ const stack = state.ctx.platform.runtimeStack();
126
+ const protectedRoots = buildProtectedRoots(stack);
111
127
  const configCache = new Map(allConfigs.map((c) => [c.source, c]));
112
128
  // Source-level git indicator (G2.1 / spellbound-heron) — the CONFIG-panel half
113
129
  // of the two summary builders (the TREE half is `FilesystemService.list`;
114
130
  // MAJOR-1 — BOTH must be enriched). CHEAP + bounded O(1)/source: for each
115
- // git-backed LOCAL source, resolve its connector root and stamp
131
+ // git-backed `host` folder (this server's or a device's — read without a spawn), resolve its root and stamp
116
132
  // `git = { isRepo, repoName, branch }` from `getSourceGitSummary` (a `.git` stat + an
117
133
  // O(1) `.git/HEAD` branch read, TTL-cached — never the whole-repo status).
118
134
  // Runs on `allConfigs` AFTER `listSourceConfigs`'s scope filter (leak-safe).
@@ -124,7 +140,7 @@ export const GET = async (req, raw) => {
124
140
  const origin = matched?.originPackage ?? null;
125
141
  const git = await resolveSourceGit(config, infra, ctx);
126
142
  const readable = await rootReadable(infra, ctx, config.source, configCache);
127
- const view = await sourceConfigView(req.session, config, infra?.projectRoot ?? '', protectedRoots, readable, !config.sync && matched && isOwnDataZoneDeclaration(matched.originPackage, matched.decl, state.ctx.selfId) ? dataSyncExclude : undefined);
143
+ const view = await sourceConfigView(req.session, config, infra?.projectRoot ?? '', stack.isDocker, protectedRoots, readable, deviceAttachVerdict(state, ctx, config.connection), !config.sync && matched && isOwnDataZoneDeclaration(matched.originPackage, matched.decl, state.ctx.selfId) ? dataSyncExclude : undefined);
128
144
  return {
129
145
  ...toPublicSourceConfig(config, view),
130
146
  // DERIVED, never persisted — the same chain the `<runtime_stack>`
@@ -141,8 +157,8 @@ export const GET = async (req, raw) => {
141
157
  kind_trust: metadata?.packageTrust ?? null,
142
158
  origin_package: origin,
143
159
  orphan: !metadata, embedding_enabled: embeddingEnabled(),
144
- canMaintain: hasRouteFeature(req.session, 'drive.mount') && hasRouteFeature(req.session, 'drive.sync')
145
- && canAccessScope(config.scope, access, 'write') && readable,
160
+ canMaintain: hasRouteFeature(req.session, 'sources.mount') && hasRouteFeature(req.session, 'sources.sync')
161
+ && canAccessScope(config.scope, access, 'write', sourceScopeOrigin(config.connection)) && readable,
146
162
  orphan_hint: metadata ? null : `Package contributing kind "${config.connection.kind}" is missing — remove the source or reinstall the package.`,
147
163
  ...(git ? { git } : {}),
148
164
  };
@@ -151,8 +167,8 @@ export const GET = async (req, raw) => {
151
167
  };
152
168
  /**
153
169
  * Resolve the source-row git indicator for `GET /sources` (G2.1). Returns
154
- * `{ isRepo: true, repoName?, branch? }` for a git-backed LOCAL source — or,
155
- * E1 (MAJOR-C), `{ isRepo: false, containsRepos: true }` for a LOCAL source
170
+ * `{ isRepo: true, repoName?, branch? }` for a git-backed `host` folder — or,
171
+ * E1 (MAJOR-C), `{ isRepo: false, containsRepos: true }` for a folder
156
172
  * whose NON-repo root contains nested depth-1 checkouts (the drop condition is
157
173
  * `isRepo || containsRepos`; without the widening the field never reaches the
158
174
  * client and the multi-repo picker is dead-on-arrival). `undefined` for
@@ -164,12 +180,12 @@ export const GET = async (req, raw) => {
164
180
  * never a host path. Fail-soft: any git problem yields `undefined`.
165
181
  */
166
182
  async function resolveSourceGit(config, infra, ctx) {
167
- if (config.connection?.kind !== 'local')
183
+ if (!config.connection || sourcePlane(config.connection.kind ?? '', config.connection.config) === 'connector')
168
184
  return undefined;
169
185
  const connector = infra?.connectors?.resolveBySource?.(config.source) ?? null;
170
- if (!(connector instanceof LocalDiskConnector))
186
+ if (!(connector instanceof HostConnector))
171
187
  return undefined;
172
- const summary = await getSourceGitSummary(connector.root);
188
+ const summary = await getSourceGitSummary(probePlaceOf(connector));
173
189
  if (!summary)
174
190
  return undefined;
175
191
  if (!summary.isRepo) {
@@ -209,8 +225,8 @@ export const POST = async (req, raw) => {
209
225
  if (sub === 'purge') {
210
226
  const ctx = extractCtx(req);
211
227
  await ensureAccess(ctx, state);
212
- if (!hasRouteFeature(req.session, 'drive.mount') || !hasRouteFeature(req.session, 'drive.sync')) {
213
- return { status: 403, body: { error: 'Index cleanup requires drive.mount and drive.sync.' } };
228
+ if (!hasRouteFeature(req.session, 'sources.mount') || !hasRouteFeature(req.session, 'sources.sync')) {
229
+ return { status: 403, body: { error: 'Index cleanup requires sources.mount and sources.sync.' } };
214
230
  }
215
231
  const body = req.body;
216
232
  const source = typeof body?.source === 'string' ? body.source : '';
@@ -221,7 +237,7 @@ export const POST = async (req, raw) => {
221
237
  }
222
238
  const { bridge, infra } = await getServices(state, ctx.projectId);
223
239
  const config = await bridge.getSourceConfig(ctx, source, toScopeAccessCtx(ctx));
224
- if (!config || !canAccessScope(config.scope, toScopeAccessCtx(ctx), 'write')) {
240
+ if (!config || !canAccessScope(config.scope, toScopeAccessCtx(ctx), 'write', sourceScopeOrigin(config.connection))) {
225
241
  return { status: 404, body: { error: 'Source not found.' } };
226
242
  }
227
243
  try {
@@ -267,6 +283,14 @@ async function handleKindsList(state) {
267
283
  packageId: m.packageId,
268
284
  trust: m.packageTrust,
269
285
  capabilities: [...m.capabilities].sort(),
286
+ // Can a shell run in this kind's sources at all — the `host` kind's own
287
+ // planes (the S1 uri-policy shell, the device's broker) or a connector that
288
+ // declares `exec`? The kernel's two plane predicates answer; the form offers
289
+ // the Exec policy bit exactly then. `execRequires` (the entry features) is
290
+ // information for its tooltip, never a reason to hide the bit — an owner may
291
+ // set `exec` for members who hold them.
292
+ shell: sourcePlane(m.kind) !== 'connector' || isConnectorPlaneExecKind(m.kind, m.capabilities),
293
+ ...(m.execRequires?.length ? { execRequires: [...m.execRequires] } : {}),
270
294
  runtimeGated: m.runtimeGated,
271
295
  urlScheme: m.uri?.suggestedScheme ?? m.kind,
272
296
  configSchema: m.configSchema,
@@ -336,6 +360,7 @@ async function withEffectiveDescription(config, state, ctx, session) {
336
360
  const matched = matchDeclaredSource(config, state.ctx.platform.packageSourceDeclarations(), {
337
361
  projectRoot: infra?.projectRoot ?? '',
338
362
  appRoot: state.ctx.platform.runtimeStack().appRoot,
363
+ isDocker: state.ctx.platform.runtimeStack().isDocker,
339
364
  });
340
365
  return {
341
366
  ...toPublicSourceConfig(config, await publicViewFor(session, ctx, config, state, infra)),
@@ -359,7 +384,7 @@ async function handleSourcesCreate(req, state, preflight = false) {
359
384
  const description = body?.description;
360
385
  // `null` = absent. A read-then-resend caller (the manage-sources skill's jq
361
386
  // projection) legitimately round-trips a field the listing never carried.
362
- const recognizesPackages = body?.recognizesPackages ?? undefined;
387
+ const packageFolders = body?.packageFolders ?? undefined;
363
388
  if (!source)
364
389
  return { status: 400, body: { error: 'Missing source' } };
365
390
  if (description !== undefined && typeof description !== 'string') {
@@ -384,72 +409,101 @@ async function handleSourcesCreate(req, state, preflight = false) {
384
409
  },
385
410
  };
386
411
  }
387
- // R2b — `recognizesPackages` is a code-LOAD surface (it turns a source into a
388
- // WASM-package source). Mirror the PATCH gate: boolean, owner/admin-effective,
389
- // local-kind only. Validated here BEFORE the access check so a malformed value
390
- // is a 400 regardless of role.
391
- if (recognizesPackages !== undefined && typeof recognizesPackages !== 'boolean') {
392
- return { status: 400, body: { error: 'recognizesPackages must be a boolean if provided' } };
393
- }
412
+ // R2b — `packageFolders` is a code-LOAD surface (it turns folders of a source
413
+ // into package sources). Mirror the PATCH gate: source-relative folders,
414
+ // owner/admin-effective, folder kind only. Validated here BEFORE the access
415
+ // check so a malformed value is a 400 regardless of role.
416
+ const packageFoldersError = packageFolders === undefined ? undefined : validatePackageFolders(packageFolders, connection)[0];
417
+ if (packageFoldersError)
418
+ return { status: 400, body: { error: packageFoldersError } };
394
419
  await ensureAccess(ctx, state);
395
- if (!hasRouteFeature(req.session, 'drive.mount')) {
396
- return { status: 403, body: { error: 'Creating sources requires drive.mount.' } };
397
- }
398
- if (recognizesPackages !== undefined && !isOwnerOrAdminEffective(req.session)) {
399
- return { status: 403, body: { error: 'Setting recognizesPackages requires an owner/admin-effective role.' } };
400
- }
401
- if (recognizesPackages === true && connection.kind !== 'local') {
402
- return { status: 400, body: { error: 'recognizesPackages may only be true for local-kind sources.' } };
403
- }
404
- // fenced-courtyard (HEALTH1 B3) — privileged-root ADD gate. `drive.mount`
420
+ // A source on the caller's OWN paired device: its owner with `device.own`
421
+ // satisfies the whole attach stack (`sources.mount` included). On the host
422
+ // plane that is the ONLY way in; every other source meets the stack.
423
+ const ownDevice = deviceAttachVerdict(state, ctx, connection);
424
+ if (!ownDevice.allowed && !hasRouteFeature(req.session, 'sources.mount')) {
425
+ return { status: 403, body: { error: 'Creating sources requires sources.mount.' } };
426
+ }
427
+ if (packageFolders !== undefined && !isOwnerOrAdminEffective(req.session)) {
428
+ return { status: 403, body: { error: 'Setting packageFolders requires an owner/admin-effective role.' } };
429
+ }
430
+ // The root PAIR is derived HERE, before any gate, so every gate below judges
431
+ // the pair that will be stored and mounted on either plane.
432
+ const paired = admitSourceRoot({
433
+ connection: connection,
434
+ stack: state.ctx.platform.runtimeStack(),
435
+ mayHandSet: hasRouteFeature(req.session, PRIVILEGED_MOUNT_FEATURE),
436
+ audit: state.ctx.hostPorts?.audit,
437
+ });
438
+ if ('status' in paired)
439
+ return paired;
440
+ const pairedConnection = paired.connection;
441
+ // fenced-courtyard (HEALTH1 B3) — privileged-root ADD gate. `sources.mount`
405
442
  // attaches ordinary sources, but attaching a source rooted IN, OVER, or AT a
406
443
  // protected host zone (App Zone, the Neuralis tree, the whole host) requires
407
- // the `drive.mount.privileged` feature (not granted below the `admin` tier).
444
+ // the `sources.privileged` feature (not granted below the `admin` tier).
408
445
  // The check is canonical
409
446
  // path CONTAINMENT against the SHARED, filter-free protected-root table — NOT
410
447
  // exact-root or a client suggestion id (both spoofable via subpath / ancestor /
411
448
  // `..` / trailing-slash / a Docker free-root attach with no suggestion). The
412
449
  // requested root is resolved with `resolveLocalRoot` — the SAME canonicaliser
413
450
  // the connector uses — so the gate sees exactly what will be mounted.
414
- // native-nightjar BFIX (B2) — the HOST plane is privileged unconditionally,
415
- // BEFORE the root-sensitive container gate below. No root-string inspection
416
- // and no `protectedRoots.length` precondition: a host-plane attach is gated
417
- // even when the runtime stack is unavailable (fail closed), and a
418
- // sub-directory root is gated exactly like `/`.
419
- // The container-plane arm is kind-AGNOSTIC (see `missingAttachFeature`).
420
- const missingForAttach = await missingAttachFeature(req.session, connection, buildProtectedRoots(state.ctx.platform.runtimeStack()), async (root) => resolveLocalRoot({ configured: root, projectRoot: (await state.infraFactory.getInfra(projectId)).projectRoot ?? '', source }).resolvedRoot);
451
+ // The DEVICE plane is decided BEFORE the root-sensitive container gate below:
452
+ // the device's owner with `device.own`, nobody else, with no root-string
453
+ // inspection and no `protectedRoots.length` precondition (fail closed even
454
+ // when the runtime stack is unavailable; a sub-directory is gated like `/`).
455
+ // The local-plane arm is kind-AGNOSTIC (see `missingAttachFeature`).
456
+ const missingForAttach = await missingAttachFeature(req.session, pairedConnection, buildProtectedRoots(state.ctx.platform.runtimeStack()), async (root, hostRoot) => resolveLocalRoot({
457
+ configured: root, hostRoot, projectRoot: (await state.infraFactory.getInfra(projectId)).projectRoot ?? '',
458
+ isDocker: state.ctx.platform.runtimeStack().isDocker, source,
459
+ }).resolvedRoot, ownDevice);
421
460
  if (missingForAttach) {
422
461
  return {
423
462
  status: 403,
424
463
  body: {
425
- error: missingForAttach.plane === 'host'
426
- ? `Attaching a host-plane source requires the ${missingForAttach.feature} feature.`
427
- : `Attaching a source rooted in a protected host zone requires the ${missingForAttach.feature} feature.`,
464
+ error: missingForAttach.plane === 'device'
465
+ ? "Only the device's owner may attach a source on it."
466
+ : missingForAttach.plane === 'owner'
467
+ ? `Attaching a source on your device requires the ${missingForAttach.feature} feature.`
468
+ : `Attaching a source rooted in a protected host zone requires the ${missingForAttach.feature} feature.`,
428
469
  },
429
470
  };
430
471
  }
472
+ const fencedAttach = await nativeFenceRefusal(state, projectId, source, pairedConnection);
473
+ if (fencedAttach)
474
+ return fencedAttach;
431
475
  const bridge = await resolveBridge(state, projectId);
432
476
  const scope = { userId: ctx.userId, projectId };
433
477
  const existing = await bridge.getSourceConfig(scope, source, toScopeAccessCtx(ctx));
434
478
  if (existing)
435
479
  return { status: 409, body: { error: `Source already exists: ${source}` } };
436
- const normalizedPerms = normalizePermissions(permissions);
480
+ const normalizedPerms = normalizePermissions(permissions, { rolePriorities: ctx.rolePriorities });
437
481
  const normalizedConnection = {
438
- kind: connection.kind,
439
- config: (connection.config ?? {}),
482
+ kind: pairedConnection.kind,
483
+ config: (pairedConnection.config ?? {}),
440
484
  };
441
485
  const kindMetadata = platformRegistries(state.ctx.platform).sourceKinds.get(normalizedConnection.kind);
442
486
  if (!kindMetadata)
443
487
  return { status: 400, body: { error: `Unknown source kind "${normalizedConnection.kind}"` } };
444
- const sourceScope = resolveRequestedSourceScope(requestedScope, kindMetadata.defaultScope, ctx);
445
- const allowedScopes = kindMetadata.allowedScopes ?? (kindMetadata.scopeSelectable ? ['project', 'user', 'agent'] : [kindMetadata.defaultScope]);
488
+ // A device folder defaults to the attaching owner's USER scope and is never
489
+ // agent-scoped: `files.read` is a member default, so a project-scoped folder on
490
+ // somebody's machine is readable project-wide the moment it exists — the owner
491
+ // widens it on purpose, never by omission.
492
+ const onDevice = sourcePlane(normalizedConnection.kind, normalizedConnection.config) === 'device';
493
+ const sourceScope = resolveRequestedSourceScope(requestedScope, onDevice ? 'user' : kindMetadata.defaultScope, ctx);
494
+ if (!sourceScope) {
495
+ return { status: 400, body: { code: 'invalid_scope', error: 'scope is "project", "user" or "agent", or an object with one of those kinds' } };
496
+ }
497
+ const allowedScopes = onDevice
498
+ ? DEVICE_FOLDER_SCOPES
499
+ : kindMetadata.allowedScopes ?? (kindMetadata.scopeSelectable ? ['project', 'user', 'agent'] : [kindMetadata.defaultScope]);
446
500
  if (!kindMetadata.scopeSelectable && sourceScope.kind !== kindMetadata.defaultScope) {
447
501
  return { status: 400, body: { error: `Kind "${kindMetadata.kind}" is project-scope only.` } };
448
502
  }
449
503
  if (!allowedScopes.includes(sourceScope.kind)) {
450
504
  return { status: 400, body: { error: `Scope "${sourceScope.kind}" is not allowed for kind "${kindMetadata.kind}".` } };
451
505
  }
452
- if (!canAccessScope(sourceScope, toScopeAccessCtx(ctx), 'write')) {
506
+ if (!canAccessScope(sourceScope, toScopeAccessCtx(ctx), 'write', sourceScopeOrigin(normalizedConnection))) {
453
507
  return { status: 403, body: { error: 'Forbidden for requested source scope' } };
454
508
  }
455
509
  // Multi-operation callers need every conditional feature refusal before an
@@ -467,8 +521,9 @@ async function handleSourcesCreate(req, state, preflight = false) {
467
521
  caller: toSyncCaller(ctx),
468
522
  ...(description !== undefined ? { description: description } : {}),
469
523
  ...(sync && isSyncConfig(sync) ? { sync: sync } : {}),
470
- ...(recognizesPackages !== undefined ? { recognizesPackages: recognizesPackages } : {}),
524
+ ...(packageFolders !== undefined ? { packageFolders: packageFolders } : {}),
471
525
  });
526
+ paired.auditHandSet?.(ctx.userId, projectId, config.source);
472
527
  const infra = await state.infraFactory.getInfra(projectId);
473
528
  return { status: 201, body: toPublicSourceConfig(config, await publicViewFor(req.session, ctx, config, state, infra)) };
474
529
  }
@@ -485,23 +540,6 @@ async function handleSourcesCreate(req, state, preflight = false) {
485
540
  function hasRouteFeature(session, featureId) {
486
541
  return hasFeature(session, featureId);
487
542
  }
488
- /**
489
- * Owner/admin-STRENGTH check (R2), keyed on the ordinal, never on a role NAME
490
- * (D-A). The built-in anchors are `owner 1 · admin 2 · manager 10 · member 20 ·
491
- * viewer 30` (lower = stronger, `SessionContext.priority`) and a custom role
492
- * declares its own number in 1..99 — so the admin ANCHOR is the threshold, and
493
- * a custom role at priority 1 or 2 passes exactly like the built-ins. A
494
- * wildcard feature grant (`'*'`) is owner-effective too, resolved through the
495
- * ONE kernel `hasFeature`. Used to gate code-LOAD surfaces like
496
- * `recognizesPackages`.
497
- */
498
- function isOwnerOrAdminEffective(session) {
499
- const s = session;
500
- const priority = typeof s?.priority === 'number' ? s.priority : undefined;
501
- if (priority !== undefined && priority <= BUILTIN_ROLE_PRIORITY.admin)
502
- return true;
503
- return hasRouteFeature(session, '*');
504
- }
505
543
  // ---------------------------------------------------------------------------
506
544
  // fenced-courtyard (HEALTH1 B3) — the per-discoverable-source access table.
507
545
  //
@@ -519,47 +557,29 @@ function isOwnerOrAdminEffective(session) {
519
557
  // is containment in BOTH directions (architect MAJOR-1).
520
558
  // ---------------------------------------------------------------------------
521
559
  /** Feature required to SEE/ADD a high-blast-radius host zone (C8 — no grant below the admin tier). */
522
- const PRIVILEGED_MOUNT_FEATURE = 'drive.mount.privileged';
560
+ const PRIVILEGED_MOUNT_FEATURE = 'sources.privileged';
523
561
  /**
524
- * Feature required to attach ANY source on the HOST plane (native-nightjar
525
- * BFIX / B2 — no grant below the admin tier).
526
- *
527
- * `drive.mount` is a MANAGER default. The privileged-root machinery above was
528
- * once scoped to `kind === 'local'`, so a new connector kind that reaches
529
- * outside the container would have walked straight past it: an ordinary
530
- * manager could attach a `host-*` source rooted at `/` and get full host
531
- * read/write over the `fs_*` tools — with no `exec.host`, no owner,
532
- * no privileged gate. (The container arm is kind-agnostic now — any
533
- * non-host-plane kind with a string `root` is gated — but rule 1 below is
534
- * still what makes the HOST plane safe independent of root shape.)
562
+ * The DEVICE plane (a folder whose stored config names a `device` — a paired
563
+ * device, outside the container) has ONE attach rule: the device's owner, holding `device.own`,
564
+ * attaches or edits a source on it, and nobody else does. agent-core's device
565
+ * gate (`devices.mayAttach`) answers both halves; brain-core keeps no device-plane
566
+ * feature of its own. `sources.mount` is a MANAGER default and never reaches a
567
+ * device: a non-owner is refused whatever the role grants, so a manager cannot
568
+ * attach someone's machine at `/` and get read/write over it through `fs_*`.
569
+ * That device's own allowlist (`neuralis device grant`) still bounds what the
570
+ * owner's source can reach.
535
571
  *
536
- * Two rules close it, and BOTH always apply on this plane:
537
- * 1. every host-plane root is privileged BY CONSTRUCTION (there is no
538
- * unprivileged host root — see `hostPlaneRequiredFeatures`), and
539
- * 2. attaching one additionally requires this feature.
540
- *
541
- * The operator-owned broker ceiling is the third, out-of-band clamp: even a
542
- * granted caller only reaches paths the operator declared. The ceiling is the
543
- * floor; the feature is the audit trail.
572
+ * The plane is the kernel's `sourcePlane` (`@neuralis/package-system/contracts`)
573
+ * over the STORED connection — imported, never re-declared. Two copies of a plane
574
+ * predicate is how the attach gate and the terminal gate drift apart while both
575
+ * look correct in isolation.
544
576
  */
545
- const HOST_PLANE_MOUNT_FEATURE = 'drive.mount.host';
546
- // `isHostPlaneKind` lives in `@neuralis/package-system/contracts` — imported,
547
- // never re-declared. Two copies of a plane predicate is how the attach gate and
548
- // the terminal gate drift apart while both look correct in isolation.
549
- /**
550
- * Features required to attach a host-plane source, independent of its root.
551
- *
552
- * There is deliberately NO root-sensitivity here: on the host plane every root
553
- * is a protected zone, so a `/home/me/notes` attach is gated exactly like `/`.
554
- * Anything less would re-open B2 through a subdirectory.
555
- */
556
- function hostPlaneRequiredFeatures() {
557
- return [HOST_PLANE_MOUNT_FEATURE, PRIVILEGED_MOUNT_FEATURE];
558
- }
577
+ /** The scopes a device folder may be attached at — never `agent` (see `handleSourcesCreate`). */
578
+ const DEVICE_FOLDER_SCOPES = ['user', 'project'];
559
579
  /**
560
580
  * Build the filter-free protected-root table from the runtime stack. App Zone,
561
581
  * the Neuralis source tree, and the whole host (`/`) all require
562
- * `drive.mount.privileged`. `/` is ALWAYS included (both Docker and native) so
582
+ * `sources.privileged`. `/` is ALWAYS included (both Docker and native) so
563
583
  * the ADD gate catches a raw `config.root:'/'` attach that has no discoverable
564
584
  * suggestion under Docker.
565
585
  */
@@ -572,16 +592,22 @@ export function buildProtectedRoots(stack) {
572
592
  }
573
593
  /**
574
594
  * Containment in BOTH directions, canonical paths. Fires when the requested
575
- * root IS, is UNDER, or is an ANCESTOR of a protected root — except `/`, which
576
- * matches only an exact whole-host request (it is everyone's ancestor).
595
+ * root IS, is UNDER, or is an ANCESTOR of a protected root — except a
596
+ * filesystem root (`/`, a Windows drive root), which matches only a whole-host
597
+ * request (it is everyone's ancestor): `/`, or any drive root (`D:\`).
598
+ *
599
+ * The comparison runs in the path flavour of the zone itself: a protected root
600
+ * is a canonical host path, so a drive-letter or UNC form IS a Windows host —
601
+ * backslashes, drive roots and case-insensitive names.
577
602
  */
578
603
  function rootMatches(requested, protectedRoot, mode = 'containment') {
579
- if (protectedRoot === '/')
580
- return requested === '/';
581
- if (requested === protectedRoot)
582
- return true;
583
- if (requested.startsWith(`${protectedRoot}/`))
584
- return true; // requested is under the zone (subpath attach)
604
+ const p = hostPathFlavour(protectedRoot);
605
+ if (isFilesystemRoot(protectedRoot))
606
+ return isFilesystemRoot(requested);
607
+ if (!p.isAbsolute(requested))
608
+ return false;
609
+ if (isPathInside(protectedRoot, requested, p))
610
+ return true; // IS the zone, or under it (subpath attach)
585
611
  // The ANCESTOR arm belongs to ATTACH, not to enumeration. Attaching `/home`
586
612
  // hands over everything below it, so exposing a zone from above is the same
587
613
  // act; LISTING `/home` one level deep does not, and the browse handler drops
@@ -591,7 +617,7 @@ function rootMatches(requested, protectedRoot, mode = 'containment') {
591
617
  // opens at `/`, so the first click would deny with no route downward. The
592
618
  // zone itself stays protected: `is-or-under` still fires on it, and the
593
619
  // handler filters protected CHILDREN out of the listing it returns.
594
- if (mode === 'containment' && protectedRoot.startsWith(`${requested}/`))
620
+ if (mode === 'containment' && isPathInside(requested, protectedRoot, p))
595
621
  return true;
596
622
  return false;
597
623
  }
@@ -619,28 +645,155 @@ export function requiredFeaturesForRoot(root, protectedRoots, kind, mode = 'cont
619
645
  * the listing projection, so "you see the path you could attach" cannot drift
620
646
  * from what attaching requires.
621
647
  *
622
- * - Host plane: every `hostPlaneRequiredFeatures()` id, with no root inspection
623
- * and no runtime-stack precondition (fail closed when the stack is absent).
624
- * - Container plane, kind-AGNOSTIC: any kind whose config carries a string
625
- * `root` — `local`, and every root-bearing kind a package registers later —
648
+ * - Device plane: the DEVICE's owner holding `device.own` (`ownDevice`,
649
+ * agent-core's device gate) and nobody else — a non-owner is refused as
650
+ * `device`, an owner without `device.own` is told the feature it lacks; no
651
+ * root inspection and no runtime-stack precondition (fail closed).
652
+ * - Local plane, kind-AGNOSTIC: any kind whose config carries a string
653
+ * `root` — the in-process folder, and every root-bearing kind a package registers later —
626
654
  * resolves through `resolveRoot` (the connector's own canonicaliser) and hits
627
655
  * the SAME containment table. Scoping this to one kind is how a newly
628
656
  * registered root-bearing kind walks straight past the gate.
629
657
  */
630
- async function missingAttachFeature(session, connection, protectedRoots, resolveRoot) {
631
- if (isHostPlaneKind(connection.kind)) {
632
- const feature = hostPlaneRequiredFeatures().find((f) => !hasRouteFeature(session, f));
633
- return feature ? { feature, plane: 'host' } : null;
658
+ async function missingAttachFeature(session, connection, protectedRoots, resolveRoot, ownDevice) {
659
+ if (sourcePlane(connection.kind, connection.config) === 'device') {
660
+ if (ownDevice.allowed)
661
+ return null;
662
+ return { feature: 'device.own', plane: ownDevice.owner ? 'owner' : 'device' };
634
663
  }
635
664
  if (protectedRoots.length === 0)
636
665
  return null;
637
- const root = connection.config?.root;
666
+ const { root, hostRoot } = (connection.config ?? {});
638
667
  if (typeof root !== 'string' || root.length === 0)
639
668
  return null;
640
- const required = requiredFeaturesForRoot(await resolveRoot(root), protectedRoots, 'add');
669
+ // Only the in-process folder reads the pair; another root-bearing kind is judged on `root`.
670
+ const required = requiredFeaturesForRoot(await resolveRoot(root, connection.kind === HOST_SOURCE_KIND ? hostRoot : undefined), protectedRoots, 'add');
641
671
  const feature = required.find((f) => !hasRouteFeature(session, f));
642
672
  return feature ? { feature, plane: 'zone' } : null;
643
673
  }
674
+ /**
675
+ * Ask agent-core's ONE device gate (`devices.mayAttach`) about the device a
676
+ * device-plane connection names. brain-core reads `config.device` as an opaque
677
+ * string and knows no device semantics; no agent-core, no device or a
678
+ * non-host kind ⇒ `{ allowed: false, owner: false }` (fail closed).
679
+ */
680
+ function deviceAttachVerdict(state, ctx, connection) {
681
+ const denied = { allowed: false, owner: false };
682
+ if (sourcePlane(connection.kind, connection.config) !== 'device')
683
+ return denied;
684
+ const device = connection.config?.device;
685
+ if (typeof device !== 'string' || !device)
686
+ return denied;
687
+ const session = {
688
+ userId: ctx.userId,
689
+ projectId: ctx.projectId,
690
+ ...(ctx.agentId ? { agentId: ctx.agentId } : {}),
691
+ callerId: '@neuralis/brain-core',
692
+ callerTrust: 'native',
693
+ grantedFeatures: [...ctx.grantedFeatures],
694
+ };
695
+ const agentCore = state.ctx.packages?.getPackageApi('@neuralis/agent-core', session);
696
+ return agentCore?.devices?.mayAttach({ userId: ctx.userId, grantedFeatures: [...ctx.grantedFeatures] }, device) ?? denied;
697
+ }
698
+ /** The 403 text of a refused edit — one wording for the route PATCH and the cross-package writer. */
699
+ function editRefusalMessage(missing) {
700
+ return missing.plane === 'device'
701
+ ? "Only the device's owner may edit a source on it."
702
+ : missing.plane === 'owner'
703
+ ? `Editing a source on your device requires the ${missing.feature} feature.`
704
+ : `Editing a source rooted in a protected host zone requires the ${missing.feature} feature.`;
705
+ }
706
+ /**
707
+ * The edit gates of a source PATCH, in order — ONE ladder for every
708
+ * source-config writer (the route PATCH, `POST policies/uri`, a widening
709
+ * `POST sources/enabled`, and the cross-package writer `sourceEditFence`), so
710
+ * none can skip a rung:
711
+ * the source's scope (another member's user/agent source needs
712
+ * `files.writeOthers`), the immutable `floor:true` rows (409 when dropped or
713
+ * altered), and what attaching it needs (`missingAttachFeature`: a device folder
714
+ * is its owner's alone, a protected-zone root needs `sources.privileged`).
715
+ * The refusal the caller answers, or null.
716
+ */
717
+ export async function sourceEditRefusal(state, editor, existing, nextPermissions, ownDevice = deviceAttachVerdict(state, editor, existing.connection)) {
718
+ const grants = { grantedFeatures: [...editor.grantedFeatures] };
719
+ if (!canAccessScope(existing.scope, { userId: editor.userId, ...(editor.agentId ? { agentId: editor.agentId } : {}), grantedFeatures: grants.grantedFeatures }, 'write', sourceScopeOrigin(existing.connection))) {
720
+ return { status: 403, body: { error: 'Forbidden for source scope' } };
721
+ }
722
+ try {
723
+ assertImmutablePolicyFloorsPreserved(existing.permissions, nextPermissions);
724
+ }
725
+ catch (err) {
726
+ if (err instanceof ImmutablePolicyFloorError)
727
+ return { status: 409, body: { code: err.code, error: err.message } };
728
+ throw err;
729
+ }
730
+ // PATCH cannot change `connection` (its `device` included — a transport flip is
731
+ // a delete and a re-attach, never an edit), but it CAN widen an existing device-plane
732
+ // source's permissions (read → write). Editing one therefore needs what
733
+ // attaching one does — the device's owner with `device.own` — otherwise a
734
+ // plain-`sources.mount` manager could escalate a read-only host source in place.
735
+ // Same rule for the local plane: a PATCH can widen permissions (read →
736
+ // write) on a source already rooted in a protected zone, so editing one
737
+ // requires the same grants attaching one does — for EVERY root-bearing kind,
738
+ // not just the folder kind.
739
+ const stack = state.ctx.platform.runtimeStack();
740
+ const editJudged = await unlessAnchorUnresolvable(missingAttachFeature(grants, existing.connection, buildProtectedRoots(stack), async (root, hostRoot) => resolveLocalRoot({
741
+ configured: root, hostRoot, projectRoot: (await state.infraFactory.getInfra(editor.projectId)).projectRoot ?? '',
742
+ isDocker: stack.isDocker, source: existing.source,
743
+ }).resolvedRoot, ownDevice));
744
+ // An anchored folder this plane cannot open is never edited (fail closed, nothing written).
745
+ if (!editJudged)
746
+ return { status: 400, body: SOURCE_ANCHOR_UNRESOLVABLE };
747
+ return editJudged.value ? { status: 403, body: { error: editRefusalMessage(editJudged.value) } } : null;
748
+ }
749
+ /**
750
+ * The native folder fence for an attach or an edit (`domain/access/nativeLocalFence.ts`):
751
+ * the 403 a fenced root answers, or null. Judged on the root as the connector
752
+ * resolves it; a device folder or another kind ⇒ never this gate's.
753
+ */
754
+ export async function nativeFenceRefusal(state, projectId, source, connection) {
755
+ if (sourcePlane(connection.kind, connection.config) !== 'local')
756
+ return null;
757
+ const stack = state.ctx.platform.runtimeStack();
758
+ if (!nativeLocalFenceApplies(stack))
759
+ return null;
760
+ const projectRoot = (await state.infraFactory.getInfra(projectId)).projectRoot ?? '';
761
+ const { root, hostRoot } = (connection.config ?? {});
762
+ const refused = await nativeLocalRootRefused({
763
+ stack,
764
+ connection,
765
+ resolvedRoot: resolveLocalRoot({ configured: typeof root === 'string' ? root : '', hostRoot, projectRoot, isDocker: stack.isDocker, source }).resolvedRoot,
766
+ projectRoot,
767
+ privilegedRoots: buildProtectedRoots(stack).map((p) => p.root),
768
+ });
769
+ return refused ? { status: 403, body: NATIVE_LOCAL_REFUSAL } : null;
770
+ }
771
+ /**
772
+ * The route PATCH's edit gates on the cross-package write (`upsertSourceConfig`):
773
+ * the SAME `sourceEditRefusal` ladder (scope, immutable floors, device owner,
774
+ * protected zone) with the route's codes and texts, then the native fence, which
775
+ * refuses only an edit that widens a fenced source; an unjudgeable shape is refused.
776
+ */
777
+ export function sourceEditFence(state) {
778
+ return async (caller, existing, next) => {
779
+ const refused = await sourceEditRefusal(state, caller, existing, next.permissions);
780
+ if (refused)
781
+ return { status: refused.status, error: refused.body.error, ...(refused.body.code ? { code: refused.body.code } : {}) };
782
+ if (!(await nativeFenceRefusal(state, caller.projectId, existing.source, existing.connection)))
783
+ return null;
784
+ let widens = true;
785
+ try {
786
+ widens = fencedEditWidens(existing, {
787
+ permissions: next.permissions,
788
+ ...(next.sync ? { sync: next.sync } : {}),
789
+ });
790
+ }
791
+ catch {
792
+ widens = true;
793
+ }
794
+ return widens ? { status: 403, error: NATIVE_LOCAL_EDIT_REFUSAL.error } : null;
795
+ };
796
+ }
644
797
  /**
645
798
  * May this caller READ the source's root — the same per-uri read predicate the
646
799
  * listing paths use. Fails closed per row: no config store, or a throwing
@@ -653,27 +806,37 @@ async function rootReadable(infra, ctx, source, configCache) {
653
806
  }
654
807
  /**
655
808
  * The view `toPublicSourceConfig` answers with. The connection config goes to
656
- * a caller who could attach this source (`drive.mount` plus the gates
809
+ * a caller who could attach this source (`sources.mount` plus the gates
657
810
  * `missingAttachFeature` runs) OR can read its root — the files there already
658
811
  * show their host path as `os_uri`, so withholding it from a reader hides
659
- * nothing; the raw last-sync error goes to a `drive.sync` holder, the tier
812
+ * nothing; the raw last-sync error goes to a `sources.sync` holder, the tier
660
813
  * `sources/sync-status` answers to. A feature, never a role name.
661
814
  */
662
- async function sourceConfigView(session, config, projectRoot, protectedRoots, readable, defaultSyncExclude) {
663
- const mayAttach = hasRouteFeature(session, 'drive.mount')
664
- && await missingAttachFeature(session, config.connection, protectedRoots, (root) => resolveLocalRoot({ configured: root, projectRoot, source: config.source }).resolvedRoot) === null;
665
- return { mayReadPaths: mayAttach || readable, maySeeSyncErrors: hasRouteFeature(session, 'drive.sync'),
666
- ...(defaultSyncExclude ? { defaultSyncExclude } : {}) };
815
+ async function sourceConfigView(session, config, projectRoot, isDocker, protectedRoots, readable, ownDevice, defaultSyncExclude) {
816
+ const resolve = (root, hostRoot) => resolveLocalRoot({ configured: root, hostRoot, projectRoot, isDocker, source: config.source }).resolvedRoot;
817
+ // A stored anchor this plane cannot open is a folder nobody may attach here
818
+ // (and it opens nowhere) — never an error that empties the whole listing.
819
+ const mayAttach = (ownDevice.allowed || hasRouteFeature(session, 'sources.mount'))
820
+ && (await unlessAnchorUnresolvable(missingAttachFeature(session, config.connection, protectedRoots, resolve, ownDevice)))?.value === null;
821
+ // The folder THIS plane opens — natively the pair's machine half, never the container spelling.
822
+ const { root, hostRoot } = config.connection.config ?? {};
823
+ const inProcess = sourcePlane(config.connection.kind, config.connection.config) === 'local';
824
+ const rootEffective = inProcess && typeof root === 'string' && root
825
+ ? resolvedLocalRootOrNull({ configured: root, hostRoot, projectRoot, isDocker, source: config.source }) ?? undefined
826
+ : undefined;
827
+ return { mayReadPaths: mayAttach || readable, maySeeSyncErrors: hasRouteFeature(session, 'sources.sync'),
828
+ ...(defaultSyncExclude ? { defaultSyncExclude } : {}), ...(rootEffective ? { rootEffective } : {}) };
667
829
  }
668
830
  /** `sourceConfigView` for a single-row answer (single GET, mutation echoes). */
669
831
  async function publicViewFor(session, ctx, config, state, infra) {
670
832
  const readable = await rootReadable(infra, ctx, config.source, new Map([[config.source, config]]));
833
+ const stack = state.ctx.platform.runtimeStack();
671
834
  const matched = !config.sync ? matchDeclaredSource(config, state.ctx.platform.packageSourceDeclarations(), {
672
- projectRoot: infra?.projectRoot ?? '', appRoot: state.ctx.platform.runtimeStack().appRoot,
835
+ projectRoot: infra?.projectRoot ?? '', appRoot: stack.appRoot, isDocker: stack.isDocker,
673
836
  }) : null;
674
837
  const defaults = matched && isOwnDataZoneDeclaration(matched.originPackage, matched.decl, state.ctx.selfId)
675
838
  ? seedExcludedMachineWrittenGlobs(state.ctx.platform.machineWrittenPaths()) : undefined;
676
- return sourceConfigView(session, config, infra?.projectRoot ?? '', buildProtectedRoots(state.ctx.platform.runtimeStack()), readable, defaults);
839
+ return sourceConfigView(session, config, infra?.projectRoot ?? '', stack.isDocker, buildProtectedRoots(stack), readable, deviceAttachVerdict(state, ctx, config.connection), defaults);
677
840
  }
678
841
  function filterSourceForList(config, ctx, requestedScope, includeObserved) {
679
842
  if (requestedScope && config.scope.kind !== requestedScope)
@@ -683,10 +846,21 @@ function filterSourceForList(config, ctx, requestedScope, includeObserved) {
683
846
  return false;
684
847
  return true;
685
848
  }
849
+ /**
850
+ * The create body's `scope`: a kind name, or an object. A complete object is taken as
851
+ * written (a foreign `userId` then meets the write `canAccessScope`); an object naming
852
+ * only a valid `kind` gets the caller's ids, like the name; any other object or name is
853
+ * `null` (400 `invalid_scope`) — never silently the kind default. Absent = the default.
854
+ */
686
855
  function resolveRequestedSourceScope(raw, defaultScope, ctx) {
687
856
  if (isSourceScope(raw))
688
857
  return raw;
689
- const requested = raw === 'project' || raw === 'user' || raw === 'agent' ? raw : defaultScope;
858
+ const named = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw.kind : raw;
859
+ if (raw && typeof raw === 'object' && named !== 'project' && named !== 'user' && named !== 'agent')
860
+ return null;
861
+ if (typeof raw === 'string' && raw !== 'project' && raw !== 'user' && raw !== 'agent')
862
+ return null;
863
+ const requested = named === 'project' || named === 'user' || named === 'agent' ? named : defaultScope;
690
864
  if (requested === 'user')
691
865
  return { kind: 'user', userId: ctx.userId };
692
866
  if (requested === 'agent')
@@ -706,7 +880,7 @@ async function handleSourcesUpdate(req, state) {
706
880
  const description = body?.description;
707
881
  // `null` = absent. A read-then-resend caller (the manage-sources skill's jq
708
882
  // projection) legitimately round-trips a field the listing never carried.
709
- const recognizesPackages = body?.recognizesPackages ?? undefined;
883
+ const packageFolders = body?.packageFolders ?? undefined;
710
884
  if (!source)
711
885
  return { status: 400, body: { error: 'Missing source' } };
712
886
  if (description !== undefined && typeof description !== 'string') {
@@ -719,59 +893,42 @@ async function handleSourcesUpdate(req, state) {
719
893
  if (enabledRaw !== undefined && typeof enabledRaw !== 'boolean' && enabledRaw !== 'auto') {
720
894
  return { status: 400, body: { error: 'enabled must be boolean or "auto" if provided' } };
721
895
  }
722
- if (recognizesPackages !== undefined && typeof recognizesPackages !== 'boolean') {
723
- return { status: 400, body: { error: 'recognizesPackages must be a boolean if provided' } };
896
+ if (packageFolders !== undefined && !Array.isArray(packageFolders)) {
897
+ return { status: 400, body: { error: 'packageFolders must be an array of folder paths if provided' } };
724
898
  }
725
899
  await ensureAccess(ctx, state);
726
- if (!hasRouteFeature(req.session, 'drive.mount')) {
727
- return { status: 403, body: { error: 'Updating sources requires drive.mount.' } };
728
- }
729
- // R2 (architect MINOR-2) — flagging a source `recognizesPackages` creates a
730
- // code-LOAD surface, so the WRITE is owner/admin-effective only, even though
731
- // other source fields are drive.mount. Deny-by-default.
732
- if (recognizesPackages !== undefined && !isOwnerOrAdminEffective(req.session)) {
733
- return { status: 403, body: { error: 'Setting recognizesPackages requires an owner/admin-effective role.' } };
900
+ // R2 (architect MINOR-2) — a `packageFolders` entry creates a code-LOAD
901
+ // surface, so the WRITE is owner/admin-effective only, even though other
902
+ // source fields are sources.mount. Deny-by-default.
903
+ if (packageFolders !== undefined && !isOwnerOrAdminEffective(req.session)) {
904
+ return { status: 403, body: { error: 'Setting packageFolders requires an owner/admin-effective role.' } };
734
905
  }
735
906
  const bridge = await resolveBridge(state, projectId);
736
907
  const scope = { userId: ctx.userId, projectId };
737
908
  const existing = await bridge.getSourceConfig(scope, source, toScopeAccessCtx(ctx));
738
909
  if (!existing)
739
910
  return { status: 404, body: { error: `Source not found: ${source}` } };
740
- if (!canAccessScope(existing.scope, toScopeAccessCtx(ctx), 'write')) {
741
- return { status: 403, body: { error: 'Forbidden for source scope' } };
742
- }
743
- try {
744
- assertImmutablePolicyFloorsPreserved(existing.permissions, permissions);
745
- }
746
- catch (err) {
747
- if (err instanceof ImmutablePolicyFloorError) {
748
- return { status: 409, body: { code: err.code, error: err.message } };
749
- }
750
- throw err;
751
- }
752
- if (recognizesPackages === true && existing.connection.kind !== 'local') {
753
- return { status: 400, body: { error: 'recognizesPackages may only be true for local-kind sources.' } };
754
- }
755
- // native-nightjar BFIX (B2) — PATCH cannot change `connection`, but it CAN
756
- // widen an existing host-plane source's permissions (read → write). Editing
757
- // one therefore needs the same grants attaching one does; otherwise a
758
- // plain-`drive.mount` manager could escalate a read-only host source in place.
759
- // Same rule for the container plane: a PATCH can widen permissions (read →
760
- // write) on a source already rooted in a protected zone, so editing one
761
- // requires the same grants attaching one does — for EVERY root-bearing kind,
762
- // not just `local`.
763
- const missingForEdit = await missingAttachFeature(req.session, existing.connection, buildProtectedRoots(state.ctx.platform.runtimeStack()), async (root) => resolveLocalRoot({ configured: root, projectRoot: (await state.infraFactory.getInfra(projectId)).projectRoot ?? '', source }).resolvedRoot);
764
- if (missingForEdit) {
765
- return {
766
- status: 403,
767
- body: {
768
- error: missingForEdit.plane === 'host'
769
- ? `Editing a host-plane source requires the ${missingForEdit.feature} feature.`
770
- : `Editing a source rooted in a protected host zone requires the ${missingForEdit.feature} feature.`,
771
- },
772
- };
911
+ const ownDevice = deviceAttachVerdict(state, ctx, existing.connection);
912
+ if (!ownDevice.allowed && !hasRouteFeature(req.session, 'sources.mount')) {
913
+ return { status: 403, body: { error: 'Updating sources requires sources.mount.' } };
914
+ }
915
+ const refused = await sourceEditRefusal(state, ctx, existing, permissions, ownDevice);
916
+ if (refused)
917
+ return refused;
918
+ const packageFoldersError = packageFolders === undefined ? undefined : validatePackageFolders(packageFolders, existing.connection)[0];
919
+ if (packageFoldersError)
920
+ return { status: 400, body: { error: packageFoldersError } };
921
+ const normalizedPerms = normalizePermissions(permissions, { rolePriorities: ctx.rolePriorities, existing: existing.permissions });
922
+ // A source the native fence refuses (it predates the fence) may still be
923
+ // narrowed — disabled, a right dropped, described — never widened.
924
+ if (await nativeFenceRefusal(state, projectId, source, existing.connection) && fencedEditWidens(existing, {
925
+ permissions: normalizedPerms, clearSync,
926
+ ...(sync ? { sync } : {}),
927
+ ...(enabledRaw !== undefined ? { enabled: enabledRaw } : {}),
928
+ ...(packageFolders !== undefined ? { packageFolders: packageFolders } : {}),
929
+ })) {
930
+ return { status: 403, body: NATIVE_LOCAL_EDIT_REFUSAL };
773
931
  }
774
- const normalizedPerms = normalizePermissions(permissions);
775
932
  try {
776
933
  const updated = await bridge.updateSourceConfig(scope, {
777
934
  agentId,
@@ -781,7 +938,7 @@ async function handleSourcesUpdate(req, state) {
781
938
  ...(sync ? { sync } : {}),
782
939
  ...(clearSync ? { clearSync: true } : {}),
783
940
  ...(enabledRaw !== undefined ? { enabled: enabledRaw } : {}),
784
- ...(recognizesPackages !== undefined ? { recognizesPackages: recognizesPackages } : {}),
941
+ ...(packageFolders !== undefined ? { packageFolders: packageFolders } : {}),
785
942
  });
786
943
  const infra = await state.infraFactory.getInfra(projectId);
787
944
  return { status: 200, body: toPublicSourceConfig(updated, await publicViewFor(req.session, ctx, updated, state, infra)) };
@@ -809,17 +966,30 @@ async function handleSourcesEnabled(req, state) {
809
966
  return { status: 400, body: { error: 'enabled must be boolean or "auto"' } };
810
967
  }
811
968
  await ensureAccess(ctx, state);
812
- if (!hasRouteFeature(req.session, 'drive.mount')) {
813
- return { status: 403, body: { error: 'Toggling source enabled state requires drive.mount.' } };
814
- }
815
969
  const bridge = await resolveBridge(state, ctx.projectId);
816
970
  const scope = { userId: ctx.userId, projectId: ctx.projectId };
817
971
  const existing = await bridge.getSourceConfig(scope, source, toScopeAccessCtx(ctx));
972
+ // The owner of a paired device manages a source on it with `device.own` —
973
+ // online or offline (D24).
974
+ if (!hasRouteFeature(req.session, 'sources.mount') && !(existing && deviceAttachVerdict(state, ctx, existing.connection).allowed)) {
975
+ return { status: 403, body: { error: 'Toggling source enabled state requires sources.mount.' } };
976
+ }
818
977
  if (!existing)
819
978
  return { status: 404, body: { error: `Source not found: ${source}` } };
820
- if (!canAccessScope(existing.scope, toScopeAccessCtx(ctx), 'write')) {
979
+ if (!canAccessScope(existing.scope, toScopeAccessCtx(ctx), 'write', sourceScopeOrigin(existing.connection))) {
821
980
  return { status: 403, body: { error: 'Forbidden for source scope' } };
822
981
  }
982
+ // Turning a source ON widens its reach, so it runs the edit ladder (a device
983
+ // folder is its owner's alone, a protected zone needs its feature). Turning it
984
+ // off narrows and stays the `sources.mount` holder's.
985
+ if (fencedEditWidens(existing, { enabled })) {
986
+ const refused = await sourceEditRefusal(state, ctx, existing, existing.permissions);
987
+ if (refused)
988
+ return refused;
989
+ if (await nativeFenceRefusal(state, ctx.projectId, source, existing.connection)) {
990
+ return { status: 403, body: NATIVE_LOCAL_EDIT_REFUSAL };
991
+ }
992
+ }
823
993
  try {
824
994
  const updated = await bridge.setSourceEnabled(scope, { source, enabled });
825
995
  const infra = await state.infraFactory.getInfra(ctx.projectId);
@@ -832,16 +1002,17 @@ async function handleSourcesEnabled(req, state) {
832
1002
  async function handleSourcesDelete(slug, req, state) {
833
1003
  const ctx = extractCtx(req);
834
1004
  await ensureAccess(ctx, state);
835
- if (!hasRouteFeature(req.session, 'drive.mount')) {
836
- return { status: 403, body: { error: 'Deleting sources requires drive.mount.' } };
837
- }
838
1005
  const bridge = await resolveBridge(state, ctx.projectId);
839
1006
  const scope = { userId: ctx.userId, projectId: ctx.projectId };
840
1007
  const configs = await bridge.listSourceConfigs(scope, toScopeAccessCtx(ctx));
841
1008
  const match = configs.find((c) => c.source.replace(/[^a-zA-Z0-9-]/g, '-') === slug);
1009
+ // The owner of a paired device removes a source on it with `device.own` (D24).
1010
+ if (!hasRouteFeature(req.session, 'sources.mount') && !(match && deviceAttachVerdict(state, ctx, match.connection).allowed)) {
1011
+ return { status: 403, body: { error: 'Deleting sources requires sources.mount.' } };
1012
+ }
842
1013
  if (!match)
843
1014
  return { status: 404, body: { error: `Source not found: ${slug}` } };
844
- if (!canAccessScope(match.scope, toScopeAccessCtx(ctx), 'write')) {
1015
+ if (!canAccessScope(match.scope, toScopeAccessCtx(ctx), 'write', sourceScopeOrigin(match.connection))) {
845
1016
  return { status: 403, body: { error: 'Forbidden for source scope' } };
846
1017
  }
847
1018
  const purgeRaw = req.query?.purge;
@@ -876,8 +1047,8 @@ async function handleSourcesSync(req, state) {
876
1047
  if (!source)
877
1048
  return { status: 400, body: { error: 'Missing source' } };
878
1049
  await ensureAccess(ctx, state);
879
- if (!hasRouteFeature(req.session, 'drive.sync')) {
880
- return { status: 403, body: { error: 'Sync requires drive.sync.' } };
1050
+ if (!hasRouteFeature(req.session, 'sources.sync')) {
1051
+ return { status: 403, body: { error: 'Sync requires sources.sync.' } };
881
1052
  }
882
1053
  const bridge = await resolveBridge(state, projectId);
883
1054
  const scope = { userId: ctx.userId, projectId };
@@ -897,7 +1068,9 @@ async function handleSourcesSync(req, state) {
897
1068
  if (!config.sync) {
898
1069
  return { status: 400, body: { error: `Source "${config.source}" has no sync configuration. Add one before triggering a sync.` } };
899
1070
  }
900
- const job = await bridge.triggerSourceSync(scope, { agentId, source, caller: toSyncCaller(ctx) });
1071
+ // `confirmEmpty` lets ONE full pass tombstone a source whose root now holds nothing.
1072
+ const confirmEmpty = body?.confirmEmpty === true;
1073
+ const job = await bridge.triggerSourceSync(scope, { agentId, source, caller: toSyncCaller(ctx), ...(confirmEmpty ? { confirmEmpty } : {}) });
901
1074
  return { status: 202, body: job };
902
1075
  }
903
1076
  /**
@@ -906,13 +1079,13 @@ async function handleSourcesSync(req, state) {
906
1079
  * (architect F4): cancel is allowed exactly where the caller could trigger a
907
1080
  * sync — the same `getSourceConfig(…, access)` visibility check — which
908
1081
  * deliberately covers background/system jobs on that source (the freeze
909
- * scenario must be killable by any drive.sync holder who can see the source).
1082
+ * scenario must be killable by any sources.sync holder who can see the source).
910
1083
  */
911
1084
  async function handleSourcesCancel(req, state) {
912
1085
  const ctx = extractCtx(req);
913
1086
  await ensureAccess(ctx, state);
914
- if (!hasRouteFeature(req.session, 'drive.sync')) {
915
- return { status: 403, body: { error: 'Cancelling sync requires drive.sync.' } };
1087
+ if (!hasRouteFeature(req.session, 'sources.sync')) {
1088
+ return { status: 403, body: { error: 'Cancelling sync requires sources.sync.' } };
916
1089
  }
917
1090
  const bridge = await resolveBridge(state, ctx.projectId);
918
1091
  const scope = { userId: ctx.userId, projectId: ctx.projectId };
@@ -935,7 +1108,7 @@ async function handleSourcesCancel(req, state) {
935
1108
  return { status: 200, body: { ok: true, cancelled } };
936
1109
  }
937
1110
  /**
938
- * POST /sources/reindex — three operations behind ONE gate (`drive.sync` +
1111
+ * POST /sources/reindex — three operations behind ONE gate (`sources.sync` +
939
1112
  * source visibility):
940
1113
  * - `{source}` — the full re-sync from scratch ("Rescan changes"): markers
941
1114
  * cleared, a full job enqueued; unchanged indexed files reuse their vectors,
@@ -951,7 +1124,7 @@ async function handleSourcesCancel(req, state) {
951
1124
  * `scope:'platform'` (`platform.vector.maintain`, whole source only) it
952
1125
  * catches up EVERY writer's points — the way back after a dense clear.
953
1126
  * The document set is the sync inventory's scope, filtered by the caller's
954
- * per-uri READ policy before anything is counted — a `drive.sync` holder never
1127
+ * per-uri READ policy before anything is counted — a `sources.sync` holder never
955
1128
  * learns the size of a file it may not read.
956
1129
  */
957
1130
  async function handleSourcesReindex(req, state) {
@@ -965,8 +1138,8 @@ async function handleSourcesReindex(req, state) {
965
1138
  // CLAUDE.md principle 12).
966
1139
  const ctx = extractCtx(req);
967
1140
  await ensureAccess(ctx, state);
968
- if (!hasRouteFeature(req.session, 'drive.sync')) {
969
- return { status: 403, body: { error: 'Reindex requires drive.sync.' } };
1141
+ if (!hasRouteFeature(req.session, 'sources.sync')) {
1142
+ return { status: 403, body: { error: 'Reindex requires sources.sync.' } };
970
1143
  }
971
1144
  const force = body?.force === true;
972
1145
  const embeddingOnly = body?.embeddingOnly === true;
@@ -1089,8 +1262,8 @@ async function handleScopedReembed(ctx, state, bridge, req) {
1089
1262
  async function handleSyncStatus(req, state) {
1090
1263
  const ctx = extractCtx(req);
1091
1264
  await ensureAccess(ctx, state);
1092
- if (!hasRouteFeature(req.session, 'drive.sync')) {
1093
- return { status: 403, body: { error: 'Sync status requires drive.sync.' } };
1265
+ if (!hasRouteFeature(req.session, 'sources.sync')) {
1266
+ return { status: 403, body: { error: 'Sync status requires sources.sync.' } };
1094
1267
  }
1095
1268
  const bridge = await resolveBridge(state, ctx.projectId);
1096
1269
  const scope = { userId: ctx.userId, projectId: ctx.projectId };
@@ -1116,10 +1289,10 @@ async function handleSourcesReset(req, state) {
1116
1289
  const ctx = extractCtx(req);
1117
1290
  await ensureAccess(ctx, state);
1118
1291
  // Reset mutates sync markers AND cancels the source's active job — it is
1119
- // a sync-shaped mutation, so it carries the same drive.sync gate + source
1292
+ // a sync-shaped mutation, so it carries the same sources.sync gate + source
1120
1293
  // visibility check as sync/cancel/reindex (architect MAJOR-2, F4 rule).
1121
- if (!hasRouteFeature(req.session, 'drive.sync')) {
1122
- return { status: 403, body: { error: 'Reset requires drive.sync.' } };
1294
+ if (!hasRouteFeature(req.session, 'sources.sync')) {
1295
+ return { status: 403, body: { error: 'Reset requires sources.sync.' } };
1123
1296
  }
1124
1297
  const bridge = await resolveBridge(state, ctx.projectId);
1125
1298
  const scope = { userId: ctx.userId, projectId: ctx.projectId };
@@ -1139,20 +1312,16 @@ async function handleSourcesReset(req, state) {
1139
1312
  * suggested kind + config.
1140
1313
  *
1141
1314
  * Suggested kinds today:
1142
- * - `app` — `{neuralisHome}/app/` (admin-only zone; per W3I the
1143
- * admin package will register a dedicated SourceKind in
1144
- * a follow-up commit; until then we suggest the path
1145
- * with `kind: 'local'` so the existing add-source flow
1146
- * works).
1315
+ * - `app` — `{neuralisHome}/app/` (admin-only zone), suggested as a
1316
+ * `host` source.
1147
1317
  * - `neuralis` — the downloaded Neuralis tree
1148
1318
  * (`/home/<user>/projects/Neuralis` on host installs,
1149
- * `os://repo` mount inside Docker). Same caveat: full
1150
- * registration happens later; today we surface the path
1151
- * with `kind: 'local'`.
1319
+ * `os://repo` mount inside Docker), suggested as a `host`
1320
+ * source.
1152
1321
  * - `projects` — the per-project zone next door (`{neuralisHome}/projects`).
1153
1322
  *
1154
1323
  * Already-attached paths are filtered out by comparing each suggestion's
1155
- * resolved root against the persisted source configs' `connection.config.root`.
1324
+ * root against every persisted source's root as THIS plane resolves it.
1156
1325
  */
1157
1326
  async function handleSourcesDiscoverable(req, state) {
1158
1327
  const ctx = extractCtx(req);
@@ -1164,19 +1333,25 @@ async function handleSourcesDiscoverable(req, state) {
1164
1333
  // without this a viewer still read every mount's name and host path. The UI
1165
1334
  // is already fail-soft on a 403 here (`FileTree` gates its fetch on the same
1166
1335
  // feature; `SourcesPanel` catches into an empty list).
1167
- if (!hasRouteFeature(req.session, 'drive.mount')) {
1168
- return { status: 403, body: { error: 'Listing discoverable source roots requires the drive.mount feature.' } };
1336
+ if (!hasRouteFeature(req.session, 'sources.mount')) {
1337
+ return { status: 403, body: { error: 'Listing discoverable source roots requires the sources.mount feature.' } };
1169
1338
  }
1170
1339
  const stack = state.ctx.platform.runtimeStack();
1171
1340
  const bridge = await resolveBridge(state, ctx.projectId);
1172
1341
  const scope = { userId: ctx.userId, projectId: ctx.projectId };
1173
1342
  const access = toScopeAccessCtx(ctx);
1174
1343
  const existing = await bridge.listSourceConfigs(scope, access);
1344
+ // Compared on the root THIS plane opens, so a natively attached folder (stored
1345
+ // under its container spelling) is not offered again.
1346
+ const attachedProjectRoot = (await state.infraFactory.getInfra(ctx.projectId)).projectRoot ?? '';
1175
1347
  const attachedRoots = new Set();
1176
1348
  for (const config of existing) {
1177
1349
  const root = config.connection?.config?.root?.trim();
1178
- if (root)
1179
- attachedRoots.add(root);
1350
+ const attached = root
1351
+ ? resolvedLocalRootOrNull({ configured: root, hostRoot: config.connection.config.hostRoot, projectRoot: attachedProjectRoot, isDocker: stack.isDocker, source: config.source })
1352
+ : null;
1353
+ if (attached !== null)
1354
+ attachedRoots.add(attached);
1180
1355
  }
1181
1356
  const suggestions = [];
1182
1357
  // Declarative `seed:'discoverable'` sources (modular-brewing-sutherland) —
@@ -1210,7 +1385,7 @@ async function handleSourcesDiscoverable(req, state) {
1210
1385
  ...(def ? { suggestedAccess: { read: !!def.read, write: !!def.write, exec: !!def.exec } } : {}),
1211
1386
  ...(decl.visibility?.seeRequires?.length ? { seeRequires: decl.visibility.seeRequires } : {}),
1212
1387
  };
1213
- if (decl.kind === 'local') {
1388
+ if (decl.kind === HOST_SOURCE_KIND) {
1214
1389
  let resolvedRoot = null;
1215
1390
  let hostRoot;
1216
1391
  if (decl.root === '${appRoot}') {
@@ -1263,10 +1438,16 @@ async function handleSourcesDiscoverable(req, state) {
1263
1438
  // shape `neuralis-operations` prescribes), and the whole-tree proposal
1264
1439
  // stays available for source work but says what it exposes and pre-fills a
1265
1440
  // `.env*` read-deny.
1441
+ //
1442
+ // A native host has no overlay and no container: its packaged operator
1443
+ // skills sit in its OWN tree (`stack.neuralisTree`, the Neuralis tree the
1444
+ // privileged-root table names there), so the skills proposal is rooted
1445
+ // there — the whole-tree proposal stays the overlay's.
1266
1446
  const neuralisMount = stack.osMounts.find((m) => m.key === 'neuralis');
1267
1447
  const neuralisRoot = neuralisMount?.containerPath ?? null;
1268
- if (neuralisRoot) {
1269
- const skillsRoot = resolvePath(neuralisRoot, 'skills');
1448
+ const skillsTree = neuralisRoot ?? (stack.isDocker ? null : stack.neuralisTree);
1449
+ if (skillsTree) {
1450
+ const skillsRoot = resolvePath(skillsTree, 'skills');
1270
1451
  // Guarded: an overlay rooted somewhere without a `skills/` folder must
1271
1452
  // not advertise a directory that is not there — silent-zero-index is the
1272
1453
  // exact trap this suggestion exists to remove.
@@ -1275,12 +1456,13 @@ async function handleSourcesDiscoverable(req, state) {
1275
1456
  id: 'discover:neuralis-skills',
1276
1457
  label: 'Neuralis Skills',
1277
1458
  description: "The deployment's own operator skills (`neuralis/skills/`). Each SKILL.md folder is a discovered source package, so the operator-command vocabulary reaches an agent's context by itself.",
1278
- suggestedKind: 'local',
1459
+ suggestedKind: HOST_SOURCE_KIND,
1279
1460
  suggestedScheme: 'neuralis-skills',
1280
- suggestedConfig: {
1281
- root: skillsRoot,
1282
- ...(neuralisMount?.hostPath ? { hostRoot: resolvePath(neuralisMount.hostPath, 'skills') } : {}),
1283
- },
1461
+ // The LOGICAL root — the tree's `skills/` on whichever plane opens the
1462
+ // source (the overlay in Docker, the host's own tree natively) — so the
1463
+ // attach survives a move between the two; the machine half derives
1464
+ // from the overlay's mount at bind time.
1465
+ suggestedConfig: { root: `${NEURALIS_TREE_ANCHOR}skills` },
1284
1466
  root: skillsRoot,
1285
1467
  icon: 'BookOpen',
1286
1468
  suggestedScope: 'user',
@@ -1299,20 +1481,17 @@ async function handleSourcesDiscoverable(req, state) {
1299
1481
  // trigger, which rides the contract rather than the form's old
1300
1482
  // hard-coded literal.
1301
1483
  suggestedSync: { trigger: 'manual' },
1302
- note: 'Owner-only. Mounted via `pnpm neuralis:mount add … --neuralis`.',
1484
+ note: neuralisRoot ? 'Owner-only. Mounted via `pnpm neuralis:mount add … --neuralis`.' : "Owner-only. This install's own packaged skills (read-only).",
1303
1485
  });
1304
1486
  }
1305
- if (!attachedRoots.has(neuralisRoot)) {
1487
+ if (neuralisRoot && !attachedRoots.has(neuralisRoot)) {
1306
1488
  suggestions.push({
1307
1489
  id: 'discover:neuralis',
1308
1490
  label: 'Neuralis Tree',
1309
1491
  description: "The deployment's own `neuralis/` folder, live from the host (scripts/, skills/, src/, config). Holds `.env` — attach it only for source work; prefer Neuralis Skills to give an agent the operator commands.",
1310
- suggestedKind: 'local',
1492
+ suggestedKind: HOST_SOURCE_KIND,
1311
1493
  suggestedScheme: 'neuralis',
1312
- suggestedConfig: {
1313
- root: neuralisRoot,
1314
- ...(neuralisMount?.hostPath ? { hostRoot: neuralisMount.hostPath } : {}),
1315
- },
1494
+ suggestedConfig: { root: NEURALIS_TREE_ANCHOR },
1316
1495
  root: neuralisRoot,
1317
1496
  icon: 'GitBranch',
1318
1497
  // Privileged host zone — default to USER scope so it is not shared
@@ -1355,7 +1534,7 @@ async function handleSourcesDiscoverable(req, state) {
1355
1534
  id: 'discover:projects',
1356
1535
  label: 'Projects Zone',
1357
1536
  description: '`{neuralisHome}/projects/` — sibling of the active project workspace.',
1358
- suggestedKind: 'local',
1537
+ suggestedKind: HOST_SOURCE_KIND,
1359
1538
  suggestedScheme: 'local',
1360
1539
  suggestedConfig: {
1361
1540
  root: stack.projectsRoot,
@@ -1365,22 +1544,30 @@ async function handleSourcesDiscoverable(req, state) {
1365
1544
  icon: 'FolderTree',
1366
1545
  });
1367
1546
  }
1368
- // Full host — only in native (non-Docker) mode, where `/` IS the host root
1369
- // and is directly readable. In Docker the host is invisible unless bound
1370
- // (`pnpm neuralis:mount add / --slug host`), which then surfaces via the
1371
- // generic mount loop below. Opt-in, gated by `drive.mount.privileged` (no
1372
- // grant below the `admin` tier), attach READ-ONLY by default
1373
- // (suggestedAccess) — the whole-host blast radius warrants deny-by-default;
1374
- // the attacher narrows the uri-policy (or widens write) before use.
1375
- if (!stack.isDocker && !attachedRoots.has('/')) {
1547
+ // Full host — only in native (non-Docker) mode, where the filesystem root IS
1548
+ // the host root and is directly readable: `/`, on Windows each drive root a
1549
+ // zone lives on (`C:\` — `/` there means "the current drive"). In Docker
1550
+ // the host is invisible unless bound (`pnpm neuralis:mount add / --slug
1551
+ // host`), which then surfaces via the generic mount loop below. Opt-in,
1552
+ // gated by `sources.privileged` (no grant below the `admin` tier), attach
1553
+ // READ-ONLY by default (suggestedAccess) — the whole-host blast radius
1554
+ // warrants deny-by-default; the attacher narrows the uri-policy (or widens
1555
+ // write) before use. The roots are the privileged table's own whole-host rows.
1556
+ const hostRoots = stack.isDocker ? [] : protectedRootPaths(stack).filter(isFilesystemRoot);
1557
+ for (const root of hostRoots) {
1558
+ if (attachedRoots.has(root))
1559
+ continue;
1560
+ const drive = /^([A-Za-z]):/.exec(root)?.[1]?.toLowerCase();
1561
+ if (root !== '/' && !drive)
1562
+ continue; // a UNC share root is not "the host"
1376
1563
  suggestions.push({
1377
- id: 'discover:host-root',
1378
- label: 'Full Host',
1379
- description: 'The entire host filesystem (`/`). Attach read-only and scope it before use.',
1380
- suggestedKind: 'local',
1381
- suggestedScheme: 'host',
1382
- suggestedConfig: { root: '/' },
1383
- root: '/',
1564
+ id: drive ? `discover:host-root:${drive}` : 'discover:host-root',
1565
+ label: drive ? `Full Host (${root})` : 'Full Host',
1566
+ description: `The entire host filesystem (\`${root}\`). Attach read-only and scope it before use.`,
1567
+ suggestedKind: HOST_SOURCE_KIND,
1568
+ suggestedScheme: drive ? `host-${drive}` : 'host',
1569
+ suggestedConfig: { root },
1570
+ root,
1384
1571
  icon: 'HardDrive',
1385
1572
  // Privileged host zone — default to USER scope (not shared project-wide).
1386
1573
  suggestedScope: 'user',
@@ -1405,7 +1592,7 @@ async function handleSourcesDiscoverable(req, state) {
1405
1592
  id: `discover:mount:${mount.key}`,
1406
1593
  label: mount.key,
1407
1594
  description: `Host bind \`${mount.hostPath ?? mount.containerPath}\` mounted at \`${mount.containerPath}\`.`,
1408
- suggestedKind: 'local',
1595
+ suggestedKind: HOST_SOURCE_KIND,
1409
1596
  suggestedScheme: scheme,
1410
1597
  suggestedConfig: {
1411
1598
  root: mount.containerPath,
@@ -1424,7 +1611,12 @@ async function handleSourcesDiscoverable(req, state) {
1424
1611
  // ADD gate in handleSourcesCreate enforces the boundary independently — the
1425
1612
  // client filter is UX only.
1426
1613
  const protectedRoots = buildProtectedRoots(stack);
1614
+ // The native folder fence: without a container no root outside the project
1615
+ // zone and the privileged rows is offered — that folder is the device's path.
1616
+ const fenceProject = nativeLocalFenceApplies(stack) ? (await state.infraFactory.getInfra(ctx.projectId)).projectRoot ?? '' : null;
1427
1617
  const visible = suggestions
1618
+ .filter((s) => fenceProject === null || s.suggestedKind !== HOST_SOURCE_KIND || !s.root
1619
+ || attachableNatively(nativeLocalPlacement(hostPathFlavour(s.root).resolve(s.root), fenceProject && resolvePath(fenceProject), protectedRoots.map((p) => p.root))))
1428
1620
  .map((s) => {
1429
1621
  // Union the authoritative protected-root features with any declaration
1430
1622
  // `visibility.seeRequires` already on the suggestion (defense-in-depth).
@@ -1443,131 +1635,51 @@ async function handleSourcesDiscoverable(req, state) {
1443
1635
  return { status: 200, body: { suggestions: visible } };
1444
1636
  }
1445
1637
  /**
1446
- * `GET /sources/browse?path=…` — one level of directory listing, the step that
1447
- * PRECEDES attaching a source.
1448
- *
1449
- * warded-wren (HOST-OSACC-FB9). This handler used to be the one route in the
1450
- * file that checked nothing: no `ensureAccess`, no in-handler feature, no
1451
- * protected-root table, and a raw `err.message` in the body. The route-level
1452
- * `feature = 'drive.read'` is a VIEWER default, so on a native install any
1453
- * signed-in reader could walk the operator's host one level at a time
1454
- * (`?path=/` → `/home` → `/root`), and on win32 enumerate the drive letters.
1455
- *
1456
- * The rule is one sentence: **you may browse exactly what you would be allowed
1457
- * to attach.** The base feature is `drive.mount` — the same gate
1458
- * `handleSourcesCreate` gives the act this one prepares — and per-path the
1459
- * SHARED protected-root table decides, so `/`, the App Zone and the Neuralis
1460
- * tree cost `drive.mount.privileged` here exactly as they do at attach time.
1461
- * `'see'` is the semantically right column; today both columns hold the same
1462
- * feature, so nothing in the suite proves the distinction — it is intent, not
1463
- * enforcement.
1638
+ * `GET /sources/browse?path=…[&device=dev_…]` — one level of folders, the step
1639
+ * that PRECEDES attaching a source: on this server, or on the caller's OWN
1640
+ * paired device. The work lives in `usecases/browseSourceFolders.ts`; this
1641
+ * handler supplies the gates this file owns (the protected-root table, the
1642
+ * native fence, the device attach gate). The rule: **you may browse exactly
1643
+ * what you would be allowed to attach.**
1464
1644
  */
1465
1645
  async function handleSourcesBrowse(req, state) {
1466
1646
  const ctx = extractCtx(req);
1647
+ // Not the security gate — the usecase's gates are. `ensureAccess` buys
1648
+ // uniformity with every sibling handler and the project-override 403 in
1649
+ // `extractCtx`, and it needs a real agent in context.
1467
1650
  await ensureAccess(ctx, state);
1468
- // Not the security gate — the gates are the feature check below and the
1469
- // per-path table. `ensureAccess` buys uniformity with every sibling handler
1470
- // and the project-override 403 in `extractCtx`.
1471
- if (!hasRouteFeature(req.session, 'drive.mount')) {
1472
- return { status: 403, body: { error: 'Browsing filesystem paths requires the drive.mount feature.' } };
1473
- }
1474
- const { readdir, realpath } = await import('node:fs/promises');
1475
- const { resolve, join, dirname } = await import('node:path');
1476
- // Container deployments: `/mounts` is the only browsable zone and `path` is
1477
- // never read, so it is NOT gated on a path the handler ignores. The fail-soft
1478
- // empty list is deliberate — "no mounts configured yet" is a normal state for
1479
- // the add-source form, not an error.
1480
- if (process.env.NEURALIS_DOCKER === 'true') {
1481
- const mountsRoot = '/mounts';
1482
- try {
1483
- const entries = await readdir(mountsRoot, { withFileTypes: true });
1484
- const dirs = entries.filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))
1485
- .map((e) => ({ name: e.name, path: join(mountsRoot, e.name), type: 'directory' }));
1486
- return { status: 200, body: { entries: dirs } };
1487
- }
1488
- catch {
1489
- return { status: 200, body: { entries: [] } };
1490
- }
1491
- }
1492
- const rawPath = (req.query?.path ?? '');
1493
- if (!rawPath)
1494
- return { status: 400, body: { error: 'Missing path parameter' } };
1495
- // A NUL byte survives `resolve()` unchanged and would slip past every string
1496
- // prefix comparison in the table below before `readdir` rejects it.
1497
- if (rawPath.includes('\0'))
1498
- return { status: 400, body: { error: BROWSE_UNREADABLE } };
1499
- // A relative path would resolve against the SERVER process cwd — never what
1500
- // the caller meant, and it also closes the drive-relative `C:foo` form.
1501
- if (!isAbsolute(rawPath))
1502
- return { status: 400, body: { error: 'Path must be absolute' } };
1503
- // ONE canonical value from here on: the gate, the root test, the readdir and
1504
- // the child paths all use it. Two resolutions is how a gate ends up judging a
1505
- // different string than the syscall receives.
1506
- const canonical = resolve(rawPath);
1507
- const protectedRoots = buildProtectedRoots(state.ctx.platform.runtimeStack());
1508
- const denied = missingBrowseFeatures(canonical, protectedRoots, state.ctx.platform.runtimeStack(), req.session, dirname);
1509
- if (denied.length > 0) {
1510
- return { status: 403, body: { error: `Browsing this path requires the ${denied[0]} feature.` } };
1511
- }
1512
- // `resolve` is purely LEXICAL, so a symlink whose literal path is innocent can
1513
- // still point at `/` or the App Zone. Re-run the same gate on the real path.
1514
- let real;
1515
- try {
1516
- real = await realpath(canonical);
1517
- }
1518
- catch {
1519
- return { status: 400, body: { error: BROWSE_UNREADABLE } };
1520
- }
1521
- if (real !== canonical) {
1522
- const deniedReal = missingBrowseFeatures(real, protectedRoots, state.ctx.platform.runtimeStack(), req.session, dirname);
1523
- if (deniedReal.length > 0) {
1524
- return { status: 403, body: { error: `Browsing this path requires the ${deniedReal[0]} feature.` } };
1525
- }
1526
- }
1527
- // Whole-host discovery by definition — the drive letters ARE the host. The
1528
- // filesystem-root fixpoint above already required the privileged feature
1529
- // (`win32.resolve('/')` is `\`, whose dirname is itself), and this second
1530
- // check keeps that true even if the table below it ever changes.
1531
- const { platform } = await import('node:os');
1532
- if (platform() === 'win32' && dirname(real) === real) {
1533
- if (!hasRouteFeature(req.session, PRIVILEGED_MOUNT_FEATURE)) {
1534
- return { status: 403, body: { error: `Browsing this path requires the ${PRIVILEGED_MOUNT_FEATURE} feature.` } };
1535
- }
1536
- const { stat } = await import('node:fs/promises');
1537
- const drives = [];
1538
- for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
1539
- try {
1540
- await stat(`${letter}:\\`);
1541
- drives.push({ name: `${letter}:\\`, path: `${letter}:\\`, type: 'directory' });
1542
- }
1543
- catch { /* skip */ }
1544
- }
1545
- return { status: 200, body: { entries: drives } };
1546
- }
1547
- try {
1548
- const entries = await readdir(real, { withFileTypes: true });
1549
- const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
1550
- .sort((a, b) => a.name.localeCompare(b.name))
1551
- .map((e) => ({ name: e.name, path: join(real, e.name), type: 'directory' }))
1552
- // Hidden-not-locked, the file's own S2 discipline: a child the caller may
1553
- // not see is simply absent, never a locked row. This is what lets the
1554
- // gate above match IS-or-UNDER instead of full containment — the parent
1555
- // stays walkable, the zone inside it does not appear.
1556
- .filter((e) => requiredFeaturesForRoot(e.path, protectedRoots, 'see', 'is-or-under')
1557
- .every((f) => hasRouteFeature(req.session, f)));
1558
- return { status: 200, body: { entries: dirs } };
1651
+ const rawPath = typeof req.query?.path === 'string' ? req.query.path : '';
1652
+ const device = typeof req.query?.device === 'string' ? req.query.device : '';
1653
+ if (device) {
1654
+ // The device plane's ONE attach gate, asked BEFORE the `sources.mount`
1655
+ // check: the owner attaches (and so browses) with `device.own` alone.
1656
+ const verdict = deviceAttachVerdict(state, ctx, { kind: HOST_SOURCE_KIND, config: { device } });
1657
+ const session = {
1658
+ userId: ctx.userId, projectId: ctx.projectId, ...(ctx.agentId ? { agentId: ctx.agentId } : {}),
1659
+ callerId: '@neuralis/brain-core', callerTrust: 'native', grantedFeatures: [...ctx.grantedFeatures],
1660
+ };
1661
+ const devices = state.ctx.packages?.getPackageApi('@neuralis/agent-core', session)?.devices;
1662
+ return browseDeviceFolders({ device, rawPath, allowed: verdict.allowed }, devices, { userId: ctx.userId, grantedFeatures: [...ctx.grantedFeatures] });
1559
1663
  }
1560
- catch (err) {
1561
- // Never the raw message: it carries the absolute path and distinguishes
1562
- // "does not exist" from "cannot be read", which together are an existence
1563
- // oracle over the host (invariant 9). The operator reads the detail in the
1564
- // server log; the caller gets one stable sentence for every failure.
1565
- console.warn('[sources/browse] readdir failed:', err instanceof Error ? err.message : String(err));
1566
- return { status: 400, body: { error: BROWSE_UNREADABLE } };
1664
+ if (!hasRouteFeature(req.session, 'sources.mount')) {
1665
+ return { status: 403, body: { error: 'Browsing filesystem paths requires the sources.mount feature.' } };
1567
1666
  }
1667
+ // The kernel's runtime stack is the ONE Docker signal; it throws before the
1668
+ // platform facts exist (fail closed).
1669
+ const stack = state.ctx.platform.runtimeStack();
1670
+ const protectedRoots = buildProtectedRoots(stack);
1671
+ return browseServerFolders({ rawPath, isDocker: stack.isDocker === true }, {
1672
+ missingFeatures: (candidate) => missingBrowseFeatures(candidate, protectedRoots, stack, req.session, dirnameOf),
1673
+ childVisible: (child) => requiredFeaturesForRoot(child, protectedRoots, 'see', 'is-or-under').every((f) => hasRouteFeature(req.session, f)),
1674
+ mayBrowseDrives: () => hasRouteFeature(req.session, PRIVILEGED_MOUNT_FEATURE),
1675
+ privilegedFeature: PRIVILEGED_MOUNT_FEATURE,
1676
+ privilegedRoots: protectedRoots.map((p) => p.root),
1677
+ // The native folder fence, the browse half: the project zone and the
1678
+ // privileged rows are browsed as before, an ANCESTOR of the project zone
1679
+ // only on the way down to it, and anything else is the device's path.
1680
+ fenceProject: async () => (nativeLocalFenceApplies(stack) ? (await state.infraFactory.getInfra(ctx.projectId)).projectRoot ?? '' : null),
1681
+ });
1568
1682
  }
1569
- /** One stable sentence for every browse failure — see the catch above. */
1570
- const BROWSE_UNREADABLE = 'That path is not available.';
1571
1683
  /**
1572
1684
  * Features the caller is MISSING to enumerate `candidate`.
1573
1685
  *
@@ -1576,7 +1688,7 @@ const BROWSE_UNREADABLE = 'That path is not available.';
1576
1688
  * all while the runtime stack is unavailable. `buildProtectedRoots(null)`
1577
1689
  * returns `[]`, and an empty table means "unrestricted", so without this
1578
1690
  * the gate is fail-OPEN exactly when the host classification is missing.
1579
- * The create gate's host-plane arm is written the same way and says so.
1691
+ * The create gate's device-plane arm is written the same way and says so.
1580
1692
  * 2. the shared protected-root table, matched IS-or-UNDER (see `RootMatchMode`).
1581
1693
  *
1582
1694
  * The root test is the `dirname(p) === p` fixpoint rather than a comparison