@openclaw/fs-safe 0.12.0 → 0.13.1

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 (395) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +12 -7
  3. package/dist/absolute-path.d.ts.map +1 -1
  4. package/dist/absolute-path.js +32 -11
  5. package/dist/advanced.d.ts +2 -0
  6. package/dist/advanced.d.ts.map +1 -1
  7. package/dist/advanced.js +2 -0
  8. package/dist/archive-input.d.ts.map +1 -1
  9. package/dist/archive-input.js +6 -2
  10. package/dist/archive-merge.d.ts.map +1 -1
  11. package/dist/archive-merge.js +15 -2
  12. package/dist/archive-native.d.ts.map +1 -1
  13. package/dist/archive-native.js +7 -19
  14. package/dist/archive-plan.js +1 -1
  15. package/dist/archive-read.d.ts.map +1 -1
  16. package/dist/archive-read.js +18 -13
  17. package/dist/archive-staging.d.ts.map +1 -1
  18. package/dist/archive-staging.js +43 -10
  19. package/dist/archive-tar-inspect.d.ts.map +1 -1
  20. package/dist/archive-tar-inspect.js +4 -1
  21. package/dist/archive-zip-admission.d.ts +1 -1
  22. package/dist/archive-zip-admission.d.ts.map +1 -1
  23. package/dist/archive-zip-admission.js +2 -2
  24. package/dist/archive-zip-directory.d.ts +3 -0
  25. package/dist/archive-zip-directory.d.ts.map +1 -1
  26. package/dist/archive-zip-directory.js +13 -2
  27. package/dist/archive-zip-loader.d.ts +3 -2
  28. package/dist/archive-zip-loader.d.ts.map +1 -1
  29. package/dist/archive-zip-loader.js +30 -4
  30. package/dist/archive-zip-manifest.d.ts +5 -0
  31. package/dist/archive-zip-manifest.d.ts.map +1 -0
  32. package/dist/archive-zip-manifest.js +22 -0
  33. package/dist/archive-zip-names.d.ts +6 -1
  34. package/dist/archive-zip-names.d.ts.map +1 -1
  35. package/dist/archive-zip-names.js +35 -14
  36. package/dist/archive-zip-preflight.d.ts.map +1 -1
  37. package/dist/archive-zip-preflight.js +3 -2
  38. package/dist/archive.d.ts.map +1 -1
  39. package/dist/archive.js +9 -4
  40. package/dist/copy-file-input.d.ts +1 -1
  41. package/dist/copy-file-input.d.ts.map +1 -1
  42. package/dist/copy-file-input.js +2 -0
  43. package/dist/darwin-acl.d.ts +4 -0
  44. package/dist/darwin-acl.d.ts.map +1 -0
  45. package/dist/darwin-acl.js +24 -0
  46. package/dist/deny-mutations.d.ts.map +1 -1
  47. package/dist/deny-mutations.js +8 -2
  48. package/dist/directory-durability.d.ts.map +1 -1
  49. package/dist/directory-durability.js +67 -23
  50. package/dist/directory-entry-path.d.ts +3 -0
  51. package/dist/directory-entry-path.d.ts.map +1 -0
  52. package/dist/directory-entry-path.js +21 -0
  53. package/dist/directory-guard.d.ts +17 -1
  54. package/dist/directory-guard.d.ts.map +1 -1
  55. package/dist/directory-guard.js +134 -48
  56. package/dist/directory-mode-node.d.ts +12 -0
  57. package/dist/directory-mode-node.d.ts.map +1 -1
  58. package/dist/directory-mode-node.js +102 -4
  59. package/dist/effective-uid.d.ts +2 -0
  60. package/dist/effective-uid.d.ts.map +1 -0
  61. package/dist/effective-uid.js +25 -0
  62. package/dist/file-handle-transfer.d.ts.map +1 -1
  63. package/dist/file-handle-transfer.js +98 -27
  64. package/dist/file-hash.d.ts.map +1 -1
  65. package/dist/file-hash.js +3 -0
  66. package/dist/file-lock-sync.d.ts.map +1 -1
  67. package/dist/file-lock-sync.js +36 -6
  68. package/dist/file-lock.d.ts.map +1 -1
  69. package/dist/file-lock.js +29 -9
  70. package/dist/file-observation.d.ts +1 -1
  71. package/dist/file-observation.d.ts.map +1 -1
  72. package/dist/file-store-boundary.d.ts +6 -2
  73. package/dist/file-store-boundary.d.ts.map +1 -1
  74. package/dist/file-store-boundary.js +20 -65
  75. package/dist/file-store-copy-source.d.ts +5 -0
  76. package/dist/file-store-copy-source.d.ts.map +1 -0
  77. package/dist/file-store-copy-source.js +31 -0
  78. package/dist/file-store-path.d.ts.map +1 -1
  79. package/dist/file-store-path.js +4 -1
  80. package/dist/file-store-sync-directory.d.ts +16 -0
  81. package/dist/file-store-sync-directory.d.ts.map +1 -0
  82. package/dist/file-store-sync-directory.js +349 -0
  83. package/dist/file-store.d.ts.map +1 -1
  84. package/dist/file-store.js +11 -28
  85. package/dist/filename.d.ts.map +1 -1
  86. package/dist/filename.js +65 -20
  87. package/dist/fs.d.ts.map +1 -1
  88. package/dist/fs.js +3 -2
  89. package/dist/guarded-mkdir.d.ts +8 -0
  90. package/dist/guarded-mkdir.d.ts.map +1 -1
  91. package/dist/guarded-mkdir.js +176 -27
  92. package/dist/guest.d.ts.map +1 -1
  93. package/dist/guest.js +4 -1
  94. package/dist/home-dir.d.ts.map +1 -1
  95. package/dist/home-dir.js +73 -10
  96. package/dist/install-path.d.ts.map +1 -1
  97. package/dist/install-path.js +54 -19
  98. package/dist/json-durable-queue-ownership.d.ts +6 -0
  99. package/dist/json-durable-queue-ownership.d.ts.map +1 -1
  100. package/dist/json-durable-queue-ownership.js +89 -42
  101. package/dist/json-durable-queue-paths.d.ts +11 -0
  102. package/dist/json-durable-queue-paths.d.ts.map +1 -0
  103. package/dist/json-durable-queue-paths.js +42 -0
  104. package/dist/json-durable-queue-read.d.ts.map +1 -1
  105. package/dist/json-durable-queue-read.js +2 -0
  106. package/dist/json-durable-queue.d.ts.map +1 -1
  107. package/dist/json-durable-queue.js +50 -58
  108. package/dist/json-store.d.ts.map +1 -1
  109. package/dist/json-store.js +5 -1
  110. package/dist/json.d.ts.map +1 -1
  111. package/dist/json.js +54 -22
  112. package/dist/local-file-access.d.ts.map +1 -1
  113. package/dist/local-file-access.js +4 -0
  114. package/dist/local-file-descriptor.d.ts +17 -0
  115. package/dist/local-file-descriptor.d.ts.map +1 -0
  116. package/dist/local-file-descriptor.js +84 -0
  117. package/dist/local-roots.d.ts.map +1 -1
  118. package/dist/local-roots.js +35 -7
  119. package/dist/move-path-cleanup.d.ts +2 -0
  120. package/dist/move-path-cleanup.d.ts.map +1 -1
  121. package/dist/move-path-cleanup.js +44 -18
  122. package/dist/move-path.d.ts.map +1 -1
  123. package/dist/move-path.js +31 -6
  124. package/dist/native-binding.d.ts +17 -0
  125. package/dist/native-binding.d.ts.map +1 -1
  126. package/dist/native-binding.js +8 -1
  127. package/dist/native-directory-observation.d.ts +17 -0
  128. package/dist/native-directory-observation.d.ts.map +1 -0
  129. package/dist/native-directory-observation.js +37 -0
  130. package/dist/native-operations.d.ts.map +1 -1
  131. package/dist/native-operations.js +6 -4
  132. package/dist/native-parent-admission.d.ts +32 -0
  133. package/dist/native-parent-admission.d.ts.map +1 -0
  134. package/dist/native-parent-admission.js +127 -0
  135. package/dist/native-pinned-write-windows.d.ts +0 -1
  136. package/dist/native-pinned-write-windows.d.ts.map +1 -1
  137. package/dist/native-pinned-write-windows.js +8 -9
  138. package/dist/native-pinned-write.d.ts.map +1 -1
  139. package/dist/native-pinned-write.js +314 -42
  140. package/dist/native-staged-file.d.ts +4 -4
  141. package/dist/native-staged-file.d.ts.map +1 -1
  142. package/dist/native-staged-file.js +20 -10
  143. package/dist/native.d.ts +1 -1
  144. package/dist/native.d.ts.map +1 -1
  145. package/dist/native.js +7 -2
  146. package/dist/output.d.ts.map +1 -1
  147. package/dist/output.js +15 -5
  148. package/dist/overwrite-file-handle.d.ts.map +1 -1
  149. package/dist/overwrite-file-handle.js +5 -1
  150. package/dist/owner-dacl.d.ts.map +1 -1
  151. package/dist/owner-dacl.js +2 -0
  152. package/dist/path-policy.d.ts.map +1 -1
  153. package/dist/path-policy.js +7 -2
  154. package/dist/path-prefix.d.ts +7 -0
  155. package/dist/path-prefix.d.ts.map +1 -0
  156. package/dist/path-prefix.js +82 -0
  157. package/dist/path-scope-lexical.d.ts.map +1 -1
  158. package/dist/path-scope-lexical.js +18 -8
  159. package/dist/path-segment-route.d.ts +7 -0
  160. package/dist/path-segment-route.d.ts.map +1 -0
  161. package/dist/path-segment-route.js +24 -0
  162. package/dist/path-suffix-aliases.d.ts +10 -0
  163. package/dist/path-suffix-aliases.d.ts.map +1 -0
  164. package/dist/path-suffix-aliases.js +386 -0
  165. package/dist/path.d.ts.map +1 -1
  166. package/dist/path.js +16 -5
  167. package/dist/permissions-windows.d.ts.map +1 -1
  168. package/dist/permissions-windows.js +14 -3
  169. package/dist/permissions.d.ts.map +1 -1
  170. package/dist/permissions.js +37 -8
  171. package/dist/pinned-mutation-admission.d.ts +25 -0
  172. package/dist/pinned-mutation-admission.d.ts.map +1 -0
  173. package/dist/pinned-mutation-admission.js +425 -0
  174. package/dist/pinned-mutation-observation.d.ts +34 -0
  175. package/dist/pinned-mutation-observation.d.ts.map +1 -0
  176. package/dist/pinned-mutation-observation.js +142 -0
  177. package/dist/pinned-mutation-shared-route.d.ts +24 -0
  178. package/dist/pinned-mutation-shared-route.d.ts.map +1 -0
  179. package/dist/pinned-mutation-shared-route.js +70 -0
  180. package/dist/pinned-open.d.ts +6 -0
  181. package/dist/pinned-open.d.ts.map +1 -1
  182. package/dist/pinned-open.js +28 -10
  183. package/dist/pinned-write-types.d.ts +75 -0
  184. package/dist/pinned-write-types.d.ts.map +1 -0
  185. package/dist/pinned-write-types.js +1 -0
  186. package/dist/pinned-write.d.ts +7 -33
  187. package/dist/pinned-write.d.ts.map +1 -1
  188. package/dist/pinned-write.js +151 -17
  189. package/dist/private-directory.d.ts.map +1 -1
  190. package/dist/private-directory.js +2 -0
  191. package/dist/private-producer-handoff.d.ts +16 -0
  192. package/dist/private-producer-handoff.d.ts.map +1 -0
  193. package/dist/private-producer-handoff.js +272 -0
  194. package/dist/private-temp-workspace.d.ts +2 -39
  195. package/dist/private-temp-workspace.d.ts.map +1 -1
  196. package/dist/private-temp-workspace.js +183 -77
  197. package/dist/publish-file.d.ts.map +1 -1
  198. package/dist/publish-file.js +38 -32
  199. package/dist/regular-file.d.ts.map +1 -1
  200. package/dist/regular-file.js +56 -45
  201. package/dist/replace-directory.d.ts.map +1 -1
  202. package/dist/replace-directory.js +12 -5
  203. package/dist/replace-file-temp-owner.d.ts +1 -0
  204. package/dist/replace-file-temp-owner.d.ts.map +1 -1
  205. package/dist/replace-file-temp-owner.js +12 -1
  206. package/dist/replace-file.d.ts.map +1 -1
  207. package/dist/replace-file.js +24 -24
  208. package/dist/root-boundary.d.ts +4 -0
  209. package/dist/root-boundary.d.ts.map +1 -1
  210. package/dist/root-boundary.js +6 -1
  211. package/dist/root-context.d.ts +12 -1
  212. package/dist/root-context.d.ts.map +1 -1
  213. package/dist/root-context.js +57 -11
  214. package/dist/root-directory-creation.d.ts +13 -0
  215. package/dist/root-directory-creation.d.ts.map +1 -0
  216. package/dist/root-directory-creation.js +212 -0
  217. package/dist/root-directory-list.d.ts +16 -4
  218. package/dist/root-directory-list.d.ts.map +1 -1
  219. package/dist/root-directory-list.js +180 -39
  220. package/dist/root-directory.d.ts +23 -0
  221. package/dist/root-directory.d.ts.map +1 -0
  222. package/dist/root-directory.js +141 -0
  223. package/dist/root-errors.d.ts.map +1 -1
  224. package/dist/root-errors.js +12 -1
  225. package/dist/root-file-final-admission.d.ts +21 -0
  226. package/dist/root-file-final-admission.d.ts.map +1 -0
  227. package/dist/root-file-final-admission.js +83 -0
  228. package/dist/root-file.d.ts.map +1 -1
  229. package/dist/root-file.js +103 -24
  230. package/dist/root-impl.d.ts +1 -0
  231. package/dist/root-impl.d.ts.map +1 -1
  232. package/dist/root-impl.js +289 -317
  233. package/dist/root-move-noreplace.d.ts +14 -0
  234. package/dist/root-move-noreplace.d.ts.map +1 -0
  235. package/dist/root-move-noreplace.js +202 -0
  236. package/dist/root-observed-path.d.ts +9 -0
  237. package/dist/root-observed-path.d.ts.map +1 -0
  238. package/dist/root-observed-path.js +95 -0
  239. package/dist/root-path-errors.d.ts +12 -0
  240. package/dist/root-path-errors.d.ts.map +1 -0
  241. package/dist/root-path-errors.js +13 -0
  242. package/dist/root-path-existing.d.ts.map +1 -1
  243. package/dist/root-path-existing.js +53 -20
  244. package/dist/root-path-observation.d.ts +63 -0
  245. package/dist/root-path-observation.d.ts.map +1 -0
  246. package/dist/root-path-observation.js +180 -0
  247. package/dist/root-path-stat.d.ts +5 -0
  248. package/dist/root-path-stat.d.ts.map +1 -0
  249. package/dist/root-path-stat.js +101 -0
  250. package/dist/root-path-symlink.d.ts.map +1 -1
  251. package/dist/root-path-symlink.js +23 -4
  252. package/dist/root-path.d.ts +11 -0
  253. package/dist/root-path.d.ts.map +1 -1
  254. package/dist/root-path.js +215 -53
  255. package/dist/root-paths-lexical.d.ts +9 -0
  256. package/dist/root-paths-lexical.d.ts.map +1 -0
  257. package/dist/root-paths-lexical.js +22 -0
  258. package/dist/root-paths.d.ts +3 -25
  259. package/dist/root-paths.d.ts.map +1 -1
  260. package/dist/root-paths.js +77 -154
  261. package/dist/root-read-admission.d.ts +26 -0
  262. package/dist/root-read-admission.d.ts.map +1 -0
  263. package/dist/root-read-admission.js +94 -0
  264. package/dist/root-remove-identity.d.ts +15 -0
  265. package/dist/root-remove-identity.d.ts.map +1 -0
  266. package/dist/root-remove-identity.js +89 -0
  267. package/dist/root-remove-receipt.d.ts +17 -0
  268. package/dist/root-remove-receipt.d.ts.map +1 -0
  269. package/dist/root-remove-receipt.js +37 -0
  270. package/dist/root-remove.d.ts +2 -1
  271. package/dist/root-remove.d.ts.map +1 -1
  272. package/dist/root-remove.js +172 -10
  273. package/dist/root-write-admission.d.ts +68 -0
  274. package/dist/root-write-admission.d.ts.map +1 -0
  275. package/dist/root-write-admission.js +322 -0
  276. package/dist/root-write-compatibility.d.ts +13 -0
  277. package/dist/root-write-compatibility.d.ts.map +1 -0
  278. package/dist/root-write-compatibility.js +74 -0
  279. package/dist/root-write-complete-parent.d.ts +42 -0
  280. package/dist/root-write-complete-parent.d.ts.map +1 -0
  281. package/dist/root-write-complete-parent.js +195 -0
  282. package/dist/root-write-publication.d.ts +24 -0
  283. package/dist/root-write-publication.d.ts.map +1 -0
  284. package/dist/root-write-publication.js +85 -0
  285. package/dist/root-write-verification.d.ts.map +1 -1
  286. package/dist/root-write-verification.js +6 -4
  287. package/dist/secret-file.d.ts.map +1 -1
  288. package/dist/secret-file.js +62 -24
  289. package/dist/secret-read-async.d.ts.map +1 -1
  290. package/dist/secret-read-async.js +18 -13
  291. package/dist/secret-read-policy.d.ts.map +1 -1
  292. package/dist/secret-read-policy.js +5 -1
  293. package/dist/secure-file.d.ts.map +1 -1
  294. package/dist/secure-file.js +92 -7
  295. package/dist/secure-temp-dir.d.ts +6 -3
  296. package/dist/secure-temp-dir.d.ts.map +1 -1
  297. package/dist/secure-temp-dir.js +121 -101
  298. package/dist/secure-temp-repair.d.ts +35 -0
  299. package/dist/secure-temp-repair.d.ts.map +1 -0
  300. package/dist/secure-temp-repair.js +106 -0
  301. package/dist/sibling-staged-file.d.ts +3 -2
  302. package/dist/sibling-staged-file.d.ts.map +1 -1
  303. package/dist/sibling-staged-file.js +179 -55
  304. package/dist/sibling-temp.d.ts.map +1 -1
  305. package/dist/sibling-temp.js +27 -13
  306. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  307. package/dist/sidecar-lock-acquire.js +44 -14
  308. package/dist/sidecar-lock-root.d.ts.map +1 -1
  309. package/dist/sidecar-lock-root.js +4 -2
  310. package/dist/staged-directory.d.ts +14 -5
  311. package/dist/staged-directory.d.ts.map +1 -1
  312. package/dist/staged-directory.js +54 -3
  313. package/dist/standalone-publication-path.d.ts +2 -0
  314. package/dist/standalone-publication-path.d.ts.map +1 -0
  315. package/dist/standalone-publication-path.js +6 -0
  316. package/dist/stat-observation.d.ts +11 -0
  317. package/dist/stat-observation.d.ts.map +1 -0
  318. package/dist/stat-observation.js +64 -0
  319. package/dist/strict-file-identity.d.ts.map +1 -1
  320. package/dist/strict-file-identity.js +39 -2
  321. package/dist/symlink-parents.d.ts.map +1 -1
  322. package/dist/symlink-parents.js +7 -2
  323. package/dist/temp-target.d.ts +2 -0
  324. package/dist/temp-target.d.ts.map +1 -1
  325. package/dist/temp-target.js +130 -3
  326. package/dist/temp-workspace-admission.d.ts +22 -0
  327. package/dist/temp-workspace-admission.d.ts.map +1 -0
  328. package/dist/temp-workspace-admission.js +374 -0
  329. package/dist/temp-workspace-child-admission.d.ts +13 -0
  330. package/dist/temp-workspace-child-admission.d.ts.map +1 -0
  331. package/dist/temp-workspace-child-admission.js +95 -0
  332. package/dist/temp-workspace-descriptor.d.ts +48 -0
  333. package/dist/temp-workspace-descriptor.d.ts.map +1 -0
  334. package/dist/temp-workspace-descriptor.js +363 -0
  335. package/dist/temp-workspace-identity.d.ts +15 -0
  336. package/dist/temp-workspace-identity.d.ts.map +1 -0
  337. package/dist/temp-workspace-identity.js +41 -0
  338. package/dist/temp-workspace-owner.d.ts +7 -6
  339. package/dist/temp-workspace-owner.d.ts.map +1 -1
  340. package/dist/temp-workspace-owner.js +121 -61
  341. package/dist/temp-workspace-permissions.d.ts +4 -0
  342. package/dist/temp-workspace-permissions.d.ts.map +1 -0
  343. package/dist/temp-workspace-permissions.js +32 -0
  344. package/dist/temp-workspace-types.d.ts +40 -0
  345. package/dist/temp-workspace-types.d.ts.map +1 -0
  346. package/dist/temp-workspace-types.js +1 -0
  347. package/dist/temp.d.ts +1 -1
  348. package/dist/temp.d.ts.map +1 -1
  349. package/dist/temp.js +1 -1
  350. package/dist/test-hooks.d.ts +7 -0
  351. package/dist/test-hooks.d.ts.map +1 -1
  352. package/dist/text-atomic.d.ts.map +1 -1
  353. package/dist/text-atomic.js +3 -1
  354. package/dist/trash.d.ts.map +1 -1
  355. package/dist/trash.js +54 -8
  356. package/dist/walk.d.ts.map +1 -1
  357. package/dist/walk.js +9 -6
  358. package/dist/windows-owner.d.ts.map +1 -1
  359. package/dist/windows-owner.js +5 -0
  360. package/dist/windows-path-alias.d.ts +39 -0
  361. package/dist/windows-path-alias.d.ts.map +1 -0
  362. package/dist/windows-path-alias.js +153 -0
  363. package/docs/advanced.md +25 -0
  364. package/docs/archive.md +23 -2
  365. package/docs/atomic.md +27 -3
  366. package/docs/copy.md +3 -0
  367. package/docs/durability.md +14 -0
  368. package/docs/errors.md +14 -6
  369. package/docs/file-store.md +24 -3
  370. package/docs/filename.md +14 -7
  371. package/docs/install-path.md +2 -2
  372. package/docs/install.md +8 -7
  373. package/docs/json-store.md +5 -1
  374. package/docs/json.md +11 -0
  375. package/docs/mutation-policy-proof.md +65 -0
  376. package/docs/native-helper.md +41 -11
  377. package/docs/native.md +32 -8
  378. package/docs/output.md +33 -11
  379. package/docs/path-prefix.md +64 -0
  380. package/docs/path-suffix-aliases.md +159 -0
  381. package/docs/path.md +11 -0
  382. package/docs/private-file-store.md +14 -0
  383. package/docs/public-api.md +9 -0
  384. package/docs/quickstart.md +1 -1
  385. package/docs/reading.md +8 -1
  386. package/docs/root.md +18 -3
  387. package/docs/secret-file.md +3 -0
  388. package/docs/secure-file.md +6 -2
  389. package/docs/security-model.md +75 -8
  390. package/docs/sidecar-lock.md +28 -1
  391. package/docs/store.md +5 -1
  392. package/docs/temp.md +260 -36
  393. package/docs/test-hooks.md +14 -0
  394. package/docs/writing.md +38 -8
  395. package/package.json +10 -10
package/docs/temp.md CHANGED
@@ -14,7 +14,89 @@ import {
14
14
 
15
15
  ## Private temp workspaces
16
16
 
17
- A private workspace is a directory created at mode `0o700` under a caller-provided temp root. It is unique per call (random suffix). Calling `cleanup()` or leaving an `await using` scope moves an unchanged workspace through a private quarantine before removal. Descriptor-bounded cleanup prevents recursive traversal of substitutions; the compatible JavaScript fallback has the narrower race contract documented below.
17
+ A private workspace is a uniquely named directory under a caller-provided temp
18
+ root. The default requested mode is `0o700`. Calling `cleanup()` or leaving an
19
+ `await using` scope moves an unchanged workspace through a private quarantine
20
+ before removal. Descriptor-bounded cleanup prevents recursive traversal of
21
+ substitutions; the compatible JavaScript fallback has the narrower race
22
+ contract documented below.
23
+
24
+ On POSIX, workspace creation verifies the supplied root and its canonical
25
+ ancestors before creating a child. Each existing directory must be owned by the
26
+ effective user or root. Group/world-writable directories must also have the
27
+ sticky bit, so ordinary system temp directories remain usable without changing
28
+ their modes. Foreign-owned directories and non-sticky writable ancestors reject
29
+ with `not-owned` or `insecure-permissions`; unavailable effective-user identity
30
+ rejects with `permission-unverified`. Existing supplied directories keep their
31
+ permissions. Missing root components are created at `0o700` and initialized
32
+ from their first exact security snapshot; if a restrictive umask changes that
33
+ mode, correction uses a verified directory descriptor.
34
+
35
+ For an already existing canonical root, discovery retains only its immutable
36
+ exact identity. Cleanup-parent retention is provisional: after any native
37
+ capability probe, creation captures and validates the complete ancestry,
38
+ re-observes the root against discovery, and associates the retained parent
39
+ descriptor. Async creation and sync creation outside the Linux/macOS
40
+ direct-mode case dispatch `mkdtemp` immediately after that synchronous boundary
41
+ without another yield or native probe. Existing aliases and missing-component
42
+ roots keep the guarded admission route.
43
+
44
+ On Linux and macOS, synchronous creation can instead use an exclusive six-character
45
+ random child name when an explicit requested mode other than `0o700` has owner
46
+ `rwx`, no special bits, and no group/world write bits. The requested mode is
47
+ passed directly to `mkdir` and the observed complete permission bits, rather
48
+ than the requested bits, are authoritative. Umask, inherited ACL state, or
49
+ inherited special bits can make that observation differ, in which case creation
50
+ corrects the mode through the retained descriptor. This mode-based optimization
51
+ does not claim that Linux and macOS have identical syscall or ACL behavior, and
52
+ POSIX mode bits do not establish ACL privacy. Creation makes at most 64 attempts;
53
+ after a name collision, each retry generates its candidate first, replays the
54
+ already admitted immutable ancestry and descriptor receipts, and then
55
+ immediately attempts exclusive creation. A colliding entry is never inspected,
56
+ adopted, corrected, registered, or deleted. The default `0o700`, async creation, and
57
+ other sync modes retain the `mkdtemp` path. That path requests initial mode
58
+ `0o700`; a result different from `dirMode` is initialized through the same
59
+ descriptor-bound correction.
60
+
61
+ The direct sync path opens the new child without following its final component
62
+ and captures one exact descriptor observation after the parent replay. The new
63
+ child's exact identity, type, owner, private bits, and complete `0o7777` mode are
64
+ checked before mode initialization. When its creation mode already
65
+ matches `dirMode` (including the default `0o700`), creation avoids an extra mode
66
+ descriptor and chmod. If the observed creation mode differs from `dirMode`, the
67
+ immediate synchronous correction consumes that one-shot observation, checks the
68
+ fresh child name, replays the parent, and applies correction through the retained
69
+ descriptor. Later admission always performs fresh descriptor and name checks.
70
+ Permission failures propagate. POSIX `dirMode`
71
+ must not grant group/world write access; it only controls the new workspace,
72
+ not existing supplied directories. After the
73
+ first exact child observation, final adoption retains a no-follow child
74
+ descriptor, rechecks complete ancestry and retained cleanup-parent authority,
75
+ and then validates the original child's descriptor and current name for exact
76
+ identity, owner, private bits, and requested mode before cleanup is registered.
77
+ Linux and macOS may replay exact identities through round-trip-safe nonnegative
78
+ numeric `dev`/`ino` projections. Initial receipts that cannot be represented
79
+ exactly stay on the BigInt path; a malformed or mismatched numeric replay fails
80
+ closed without an exact retry.
81
+ Parent or child replacements observed during creation reject before cleanup
82
+ ownership is registered. Unverified artifacts are left in place for
83
+ caller-directed recovery.
84
+
85
+ On Windows, POSIX mode/UID metadata does not establish ACL privacy, and these
86
+ factories neither claim nor initialize a POSIX `dirMode`; every requested value
87
+ uses the identity-only path without opening a mode descriptor or applying chmod.
88
+ Callers must supply a root with trusted ACLs that protect its children and
89
+ ancestors; exact pathname identity checks still apply. `cleanupSafety` controls
90
+ removal capability, not Windows ACL admission.
91
+
92
+ Root aliases already present at entry retain their historical support and are
93
+ canonicalized. Callers remain responsible for choosing trusted root paths and
94
+ excluding hostile peers with the same filesystem authority. Node's pathname
95
+ `mkdir`/`mkdtemp` calls do not atomically return a creation descriptor: identity
96
+ checks detect observed substitutions but cannot prove provenance against every
97
+ same-privilege replacement before the first observation. Descriptor chmod
98
+ cannot be redirected to a subsequently substituted pathname. Native bounded
99
+ cleanup does not upgrade the creation operation to an atomic namespace boundary.
18
100
 
19
101
  ### `tempWorkspace`
20
102
 
@@ -76,18 +158,36 @@ verification can still redirect the final pathname removal.
76
158
 
77
159
  Set `cleanupSafety: "require-bounded"` when that concurrent attacker is in scope.
78
160
  Creation then requires native no-replace directory rename, native owned-tree
79
- removal, and a retained parent descriptor **before** `mkdtemp` creates
80
- a child. If any capability is unavailable, creation throws
81
- `FsSafeError("helper-unavailable")`; no child is created and a scoped callback is
82
- not called. The compatible default retains its fallback even if process-global
83
- native mode is `require`; select `require-bounded` to make cleanup capability
84
- mandatory for this API.
161
+ removal, and a readable retained parent descriptor **before** child creation.
162
+ On POSIX, the final requested `dirMode` must also include owner read
163
+ and search (`(dirMode & 0o500) === 0o500`). Preflight failure throws
164
+ `FsSafeError("helper-unavailable")` without creating a child or calling a scoped
165
+ callback. The child descriptor is opened
166
+ while the new directory still has its private creation mode, before an explicit
167
+ `dirMode` can lower access. Retaining a read descriptor does not bypass the
168
+ POSIX final-mode requirement: enumeration reopens the directory relative to
169
+ that descriptor. Compatible mode accepts these restrictive modes but selects
170
+ the JavaScript fallback and does not retain native traversal authority for the
171
+ child, even if the caller later restores its permissions. Windows cleanup
172
+ does not use this POSIX mode gate. A search-only descriptor remains valid identity
173
+ evidence but is never native traversal authority: compatible cleanup selects
174
+ the JavaScript fallback, while `require-bounded` rejects and leaves the
175
+ unregistered child in place for caller-directed recovery. The compatible
176
+ default retains its fallback even if process-global native mode is `require`;
177
+ select `require-bounded` to make cleanup capability mandatory for this API.
85
178
 
86
179
  On Linux, bounded cleanup requires a successful runtime probe of the exact
87
180
  `openat2` child-directory flags, including `RESOLVE_NO_XDEV`, against the retained
88
181
  parent descriptor. If the kernel or seccomp policy denies that capability,
89
182
  compatible mode uses the guarded JavaScript fallback; `require-bounded` rejects
90
- before child creation. The probe runs once at creation, without filesystem mutation.
183
+ before child creation. For eligible modes, the probe runs once at creation,
184
+ without filesystem mutation.
185
+
186
+ Cleanup does not repair POSIX workspace or descendant permissions. In compatible
187
+ mode, a restrictive workspace mode or caller-created unreadable descendants
188
+ can prevent recursive removal; native bounded cleanup can also encounter later
189
+ permission changes or inaccessible descendants. These remain operational
190
+ cleanup failures, with the propagation and recovery behavior described below.
91
191
 
92
192
  Bounded cleanup checks the parent and public workspace identity, quarantines
93
193
  the direct child without replacement, and verifies the quarantine against the
@@ -183,12 +283,20 @@ try {
183
283
  type TempWorkspaceOptions = {
184
284
  rootDir: string; // parent directory for workspaces
185
285
  prefix: string; // dir prefix (sanitized)
186
- dirMode?: number; // dir mode; default 0o700
286
+ dirMode?: number; // new workspace mode; default 0o700; no POSIX group/world write
187
287
  mode?: number; // file write mode; default 0o600
188
288
  cleanupSafety?: "compatible" | "require-bounded"; // default compatible
189
289
  };
190
290
  ```
191
291
 
292
+ On Windows, caller-provided workspace roots and workspace leaf names reject
293
+ NTFS alternate-stream and directory-index namespace spellings before directory
294
+ creation or file access. The lower-level temp and sibling helpers apply the
295
+ same admission to supplied roots, sibling directories, and callback-selected
296
+ final paths. Prefixes and `tempFile()` filenames keep their documented
297
+ sanitization behavior; ordinary colon-bearing POSIX roots and leaf names remain
298
+ valid.
299
+
192
300
  ## Advanced temp primitives
193
301
 
194
302
  When you don't need the stable workspace abstraction, the lower-level temp-file
@@ -212,6 +320,18 @@ try {
212
320
  }
213
321
  ```
214
322
 
323
+ Options:
324
+
325
+ ```ts
326
+ type TempFileOptions = {
327
+ rootDir?: string;
328
+ prefix: string;
329
+ fileName?: string;
330
+ onCleanupError?: (error: unknown) => void;
331
+ cleanupSafety?: "compatible" | "require-bounded"; // default compatible
332
+ };
333
+ ```
334
+
215
335
  Returns:
216
336
 
217
337
  ```ts
@@ -224,9 +344,30 @@ type TempFile = {
224
344
  };
225
345
  ```
226
346
 
227
- Cleanup captures the directory identity at creation time. If that path is
228
- renamed away and replaced, cleanup preserves the replacement rather than
229
- recursively deleting a directory it did not create.
347
+ The default `cleanupSafety: "compatible"` retains the historical temp-file
348
+ behavior without loading or probing the native helper. Cleanup captures the
349
+ directory identity at creation time and preserves a replacement observed by
350
+ its pre-removal identity check. That check and pathname-recursive removal are
351
+ separate operations, however: a same-privilege peer can substitute a directory
352
+ in the final gap and redirect recursive traversal. Process-exit cleanup has the
353
+ same compatible contract.
354
+
355
+ The bounded mode requires an existing supplied root and applies the same
356
+ trusted-ancestry admission as private temp workspaces. On POSIX it finalizes
357
+ the new directory to `0o700` through its retained descriptor; Windows keeps
358
+ identity checks without treating POSIX modes as ACL privacy. Unverified
359
+ creation artifacts are preserved for caller-directed recovery.
360
+
361
+ Set `cleanupSafety: "require-bounded"` to require the retained-parent,
362
+ no-replace quarantine, and descriptor-bounded owned-tree removal described for
363
+ [private temp workspaces](#private-temp-workspaces). Admission, including the
364
+ runtime probe, completes before `mkdtemp`; unavailable support throws
365
+ `FsSafeError("helper-unavailable")` before a child is created. Manual, disposal,
366
+ scoped, and process-exit cleanup then share one owner, and no pathname-recursive
367
+ fallback is used. `cleanup()` still resolves `Promise<void>`: operational
368
+ cleanup errors are passed to `onCleanupError` when supplied and otherwise
369
+ suppressed for compatibility. The bounded POSIX final-entry unlink limit still
370
+ applies.
230
371
 
231
372
  ### `withTempFile`
232
373
 
@@ -270,8 +411,13 @@ and current pathname are rejected. The callback must finish and close its
270
411
  writer before returning. Its return value is preserved as `result`.
271
412
 
272
413
  Generated temp filenames suffix Windows reserved-device basenames on every
273
- platform. A completed sibling staging component that still resolves as a
274
- Windows device alias rejects with `invalid-path` before hooks or producers run.
414
+ platform. Before either an ordinary or isolated producer runs, the completed staging
415
+ name must be a nonempty, non-dot path component with no POSIX or Windows
416
+ separator, C0/C1 control, Windows-invalid punctuation or stream colon. Windows
417
+ reserved devices and trailing-dot/space aliases are rejected on every host.
418
+ The helper joins that validated component to the guarded or owned directory and
419
+ verifies that the result is a direct child. An invalid completion rejects with
420
+ `invalid-path` without calling the producer or its pre-write hook.
275
421
 
276
422
  The helper retains one descriptor through requested mode application, opt-in
277
423
  file synchronization, rename, and publication verification. It opens read-only
@@ -313,23 +459,35 @@ owned workspace cleanup, including partial output, subject to directory
313
459
  identity checks and I/O failures. The callback must still finish and close its
314
460
  writer before returning.
315
461
 
316
- After the callback succeeds, `Root.move` checks source aliases and moves the
317
- output to the ordinary sibling path before file admission. An escaping symlink
318
- can fail with `path-alias` at this step. Rejected output still inside the owned
319
- workspace follows its cleanup contract. Once output moves to the sibling path,
320
- failures before file adoption retain it for caller-directed recovery, as above.
321
- File admission, requested modes, sync options, and final rename keep their
322
- existing contracts; `resolveFinalPath(result)` still names a direct child of `dir`.
462
+ After the callback succeeds, an available native helper uses guarded
463
+ no-replace `Root.move`. Native-off operation admits the completed regular file
464
+ with a retained descriptor. On Windows, that branch first requests write-only
465
+ access so admission does not request completed-file data reads; access or provider
466
+ rejections fall back to the historical read-only or read/write open before admission.
467
+ It then creates the randomized sibling with an atomic
468
+ no-clobber hard link and verifies the expected two-link transition. On Windows,
469
+ it opens and verifies a sibling descriptor before closing the source descriptor,
470
+ keeping the file pinned while allowing the private name to disappear on runtimes
471
+ with legacy deletion behavior. It removes the private name before continuing.
472
+ If legacy deletion clears the completed file's read-only attribute, the helper
473
+ restores it through the retained sibling descriptor and verifies its mode and
474
+ identity before publication. Restoration failures reject the operation.
475
+ An escaping symlink fails with `path-alias`, and
476
+ a filesystem without either handoff reports `helper-unavailable`. The retained
477
+ descriptor carries file admission, requested modes, sync options, and final
478
+ rename with their existing contracts; `resolveFinalPath(result)` still names a
479
+ direct child of `dir`.
323
480
 
324
481
  The isolated path retains exact bigint identities for both the parent and the
325
482
  workspace and rechecks them before moving output to the sibling path. An
326
- observed replacement is rejected. Cleanup uses the existing
327
- [`withTempFile` ownership contract](#withtempfile), backed by [`tempFile`](#tempfile):
328
- moving or replacing the parent or workspace can leave the original or
329
- replacement paths in place. This option does not promise cleanup through a
330
- retained directory after a rename, stronger permissions, or additional crash
331
- durability. Omitting it preserves the direct sibling callback path and
332
- unadmitted partial-file retention.
483
+ observed replacement is rejected. This internal use of [`withTempFile`](#withtempfile)
484
+ does not expose `cleanupSafety` and uses compatible cleanup: moving or replacing
485
+ the parent or workspace can leave artifacts, and a workspace substituted in
486
+ the final check-to-recursive-removal gap can redirect traversal. It does not
487
+ promise cleanup through a retained directory after a rename, bounded cleanup,
488
+ stronger permissions, or additional crash durability. Omitting producer
489
+ isolation preserves the direct sibling callback path and unadmitted
490
+ partial-file retention.
333
491
 
334
492
  On POSIX, admission uses no-follow and nonblocking open flags, so a FIFO swap
335
493
  does not block the helper. Windows retains Node's guarded pathname-open behavior
@@ -343,8 +501,11 @@ Identity checks and pathname rename/unlink are separate syscalls, not atomic
343
501
  conditional mutations. A hostile process can still replace a leaf or parent in
344
502
  the final syscall gap or mutate an open file's contents. Use an approved writable
345
503
  directory and cooperative locking or OS isolation; a moved parent can leave an
346
- unpublished original temp behind. Observed replacements are preserved, but
347
- arbitrary concurrent namespace changes cannot be prevented by these helpers.
504
+ unpublished original temp behind. Replacements observed before the final
505
+ namespace mutation are preserved, but that observation is not an atomic
506
+ condition on the later unlink or rename. Private producer isolation also has
507
+ the compatible recursive-cleanup gap described above. Arbitrary concurrent
508
+ namespace changes cannot be prevented by these helpers.
348
509
 
349
510
  By default the helper attempts to set `dir` to `dirMode` (default `0o700`)
350
511
  through the shared verified POSIX directory-descriptor helper. Only the actual
@@ -375,12 +536,18 @@ await writeViaSiblingTempPath({
375
536
  If `replaceFileAtomic` does what you need, prefer that. Use
376
537
  `writeViaSiblingTempPath` when the producer needs a concrete temp pathname but
377
538
  the final destination still needs root-boundary checks.
378
- Its private workspace uses the same identity-aware directory cleanup as
379
- `tempFile()`: moving and replacing the workspace preserves the replacement.
539
+ Its private workspace uses `tempFile()`'s compatible identity-aware cleanup.
540
+ It preserves replacements observed before removal, but retains the final
541
+ pathname-recursive-removal gap described above; this helper does not expose
542
+ `cleanupSafety: "require-bounded"`.
380
543
  The callback staging component is capped at 255 bytes as written and under NFC and NFD by
381
544
  shortening only an overlong embedded destination tail, while preserving an
382
545
  extension when possible. Short callback paths and the final target stay
383
- unchanged. This workspace owns its contents, unlike the unadmitted sibling
546
+ unchanged. An unusable target basename causes `fallbackFileName` to pass through
547
+ the same basename, character, reserved-device, and length sanitization before it
548
+ is embedded; if neither candidate is usable, the fixed tail `file` is used. The
549
+ completed component is then checked as a direct child before test hooks or the
550
+ producer run. This workspace owns its contents, unlike the unadmitted sibling
384
551
  pathname above.
385
552
 
386
553
  ## Secure temp root
@@ -400,6 +567,7 @@ Consumers that only need this resolver can use the narrow package subpath:
400
567
  import {
401
568
  resolveSecureTempRoot,
402
569
  type ResolveSecureTempRootOptions,
570
+ type SecureTempRootDescriptorAdapter,
403
571
  } from "@openclaw/fs-safe/secure-temp-root";
404
572
  ```
405
573
 
@@ -424,12 +592,13 @@ type ResolveSecureTempRootOptions = {
424
592
  getuid?: () => number | undefined;
425
593
  tmpdir?: () => string;
426
594
  accessSync?: typeof import("node:fs").accessSync;
427
- chmodSync?: typeof import("node:fs").chmodSync;
595
+ chmodSync?: typeof import("node:fs").chmodSync; // deprecated, never read or called
596
+ descriptor?: SecureTempRootDescriptorAdapter; // complete bundle; see below
428
597
  lstatSync?: (path: string) => {
429
598
  isDirectory(): boolean;
430
599
  isSymbolicLink(): boolean;
431
- mode?: number;
432
- uid?: number;
600
+ mode?: number | bigint;
601
+ uid?: number | bigint;
433
602
  };
434
603
  mkdirSync?: (
435
604
  path: string,
@@ -448,6 +617,61 @@ the fallback to mode `0o700` where mode bits apply. If it cannot establish that
448
617
  state, it throws an ordinary `Error`; there is no native mode or
449
618
  `helper-unavailable` branch on this API.
450
619
 
620
+ Existing secure directories retain the one-`lstat`/access fast path, with no
621
+ descriptor open or chmod. On POSIX, a directory created by this call is instead
622
+ finalized through a pinned descriptor and must finish at exactly `0o700`, even
623
+ when a privileged caller can access its initial restrictive mode. A concurrent
624
+ recursive-`mkdir` winner is inspected as an untrusted existing directory.
625
+ Broad-mode repair also uses a pinned descriptor; there is no pathname chmod.
626
+
627
+ Repair and finalization require a known nonnegative safe-integer UID and exact
628
+ bigint device, inode, owner, mode, and directory-type facts. They open with
629
+ `O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK`, verify the descriptor and
630
+ current directory entry against the initial receipt before `fchmod`, then
631
+ verify identity, permissions, and write/search access again before closing.
632
+ Trailing separators are stripped only for entry inspection, preserving filesystem
633
+ roots and symlink-sensitive `..` components. Unknown, numeric, or malformed
634
+ identity facts cannot authorize a mutation. A failed chmod is tolerated only for
635
+ the existing `EPERM`/`EACCES`/`ENOENT` cases when the same exact pinned directory
636
+ has concurrently become safe and accessible; it emits no repair warning.
637
+
638
+ There is no search-only descriptor or `/proc` fallback. A newly created `000`
639
+ directory that cannot be opened read-only is left in place; the resolver tries
640
+ the secure fallback or throws. Actual Windows uses directory-type and access
641
+ checks and performs no POSIX chmod, regardless of an injected `platform` value.
642
+ Failures keep the ordinary `Error` contract; available underlying repair and
643
+ close failures are retained in `cause`, including paired errors.
644
+
645
+ The optional descriptor adapter is a complete authority bundle:
646
+
647
+ ```ts
648
+ type SecureTempRootDescriptorAdapter = {
649
+ lstatSync(path: string, options: { bigint: true }): Pick<import("node:fs").BigIntStats,
650
+ "dev" | "ino" | "uid" | "mode" | "isDirectory" | "isSymbolicLink">;
651
+ fstatSync(fd: number, options: { bigint: true }): ReturnType<SecureTempRootDescriptorAdapter["lstatSync"]>;
652
+ openSync(path: string, flags: number): number;
653
+ fchmodSync(fd: number, mode: number): void;
654
+ closeSync(fd: number): void;
655
+ constants: { O_RDONLY: number; O_DIRECTORY: number; O_NOFOLLOW: number; O_NONBLOCK: number };
656
+ };
657
+ ```
658
+
659
+ Options, adapter functions, and flags are captured synchronously before callbacks.
660
+ On POSIX a complete bundle supplies exact admission and repair observations,
661
+ taking precedence over the legacy `lstatSync` hook. Partial bundles or unavailable
662
+ flags make repair/finalization unavailable. Injecting `lstatSync`, `accessSync`,
663
+ or `mkdirSync` disables the default host descriptor bundle. Injected observations
664
+ also require an explicit `mkdirSync` for creation; supplying a descriptor bundle
665
+ likewise never implicitly authorizes host mkdir. A custom mkdir requires the
666
+ complete descriptor bundle for POSIX finalization. The deprecated `chmodSync`
667
+ option is inert and alone does not disable normal host behavior.
668
+
669
+ These checks bind chmod to the admitted object and reject observed replacements;
670
+ the returned path is not a retained capability. Pathname access and identity
671
+ checks remain separate syscalls, and another process can replace the path after
672
+ the final check. Applications still need a trusted namespace or OS isolation
673
+ when other processes can mutate it.
674
+
451
675
  ## Common patterns
452
676
 
453
677
  ### Build something, atomically place it
@@ -31,10 +31,17 @@ type FsSafeTestHooks = {
31
31
  afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
32
32
  beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
33
33
  afterOpen?: (filePath: string, handle: FileHandle) => Promise<void> | void;
34
+ afterOpenedPathIdentityCheck?: (filePath: string, handle: FileHandle) => Promise<void> | void;
35
+ afterRootReadPathResolution?: (filePath: string) => Promise<void> | void;
36
+ beforeRootReadFinalFence?: (filePath: string, handle: FileHandle) => Promise<void> | void;
37
+ afterRootReadFinalPathIdentityCheck?: (filePath: string, handle: FileHandle) => void;
34
38
  beforeArchiveOutputMutation?: (operation: "mkdir" | "chmod", targetPath: string) => Promise<void> | void;
35
39
  beforeFileStorePruneDescend?: (dirPath: string) => Promise<void> | void;
36
40
  beforeFileStoreSyncPrivateWrite?: (filePath: string) => void;
37
41
  beforeRootFallbackMutation?: (operation: "mkdir" | "move" | "remove", targetPath: string) => Promise<void> | void;
42
+ beforeRootStatObservation?: (targetPath: string) => Promise<void> | void;
43
+ beforeRootStatInitialObservation?: (targetPath: string) => Promise<void> | void;
44
+ beforeRootListObservation?: (directoryPath: string, withFileTypes: boolean) => Promise<void> | void;
38
45
  afterPinnedWriteFallbackRename?: (targetPath: string) => Promise<void> | void;
39
46
  beforeSiblingTempWrite?: (tempPath: string) => Promise<void> | void;
40
47
  beforeSidecarLockSnapshotOpen?: (lockPath: string) => Promise<void> | void;
@@ -49,10 +56,17 @@ type FsSafeTestHooks = {
49
56
  | `afterPreOpenLstat` | A pre-open `lstat` has just resolved. Use this to swap a path between validation and open. |
50
57
  | `beforeOpen` | The library is about to call `open(path, flags)`. Use this to inject a TOCTOU window. |
51
58
  | `afterOpen` | An open just succeeded. Use this to mutate state before the post-open identity check runs. |
59
+ | `afterOpenedPathIdentityCheck` | A standalone local-file or absolute copy-source descriptor matches its pathname and the generic opened-path resolver is about to run. Root reads use their final admission hooks instead. |
60
+ | `afterRootReadPathResolution` | A Root read path was resolved and has not yet entered local-file open admission. |
61
+ | `beforeRootReadFinalFence` | A Root read descriptor passed pre-open/descriptor identity and hardlink checks and is about to enter its single final root/path/canonical-path/root admission fence. |
62
+ | `afterRootReadFinalPathIdentityCheck` | The final pathname-to-descriptor comparison passed and the second root check has not run. This hook is synchronous-only. |
52
63
  | `beforeArchiveOutputMutation` | Archive staging is about to create a directory or apply a mode. |
53
64
  | `beforeFileStorePruneDescend` | File-store pruning is about to descend into a directory. |
54
65
  | `beforeFileStoreSyncPrivateWrite` | A synchronous private-store write is about to mutate its target. |
55
66
  | `beforeRootFallbackMutation` | A guarded JS root fallback is about to mkdir, move, or remove. |
67
+ | `beforeRootStatObservation` | `Root.stat()` admitted its exact target and parent and is about to collect returned metadata. |
68
+ | `beforeRootStatInitialObservation` | `Root.stat()` admitted its exact parent and is about to inspect the target for the first time. |
69
+ | `beforeRootListObservation` | `Root.list()` admitted its exact selected directory and is about to collect names and optional metadata. |
56
70
  | `afterPinnedWriteFallbackRename` | A fallback rename committed and post-commit identity checks have not run yet. |
57
71
  | `beforeSiblingTempWrite` | A sibling temp file exists and its writer is about to run. |
58
72
  | `beforeSidecarLockSnapshotOpen` | A sidecar lock was inspected and is about to be opened for a bounded snapshot read. |
package/docs/writing.md CHANGED
@@ -29,8 +29,8 @@ await fs.mkdir("snapshots/2026/05");
29
29
  ## What replacement writes do
30
30
 
31
31
  1. Resolve the relative target against the canonical root and reject anything that escapes (`outside-workspace`).
32
- 2. If `mkdir: true`, create missing parent directories relative to a pinned parent fd in the native path, or with per-component identity guards in the JavaScript fallback.
33
- 3. Pin or guard the parent directory for the selected mechanism. Native operations use a parent fd; guarded JavaScript verifies directory identity before and after mutation. The JavaScript check cannot make the intervening pathname syscall atomic, so a same-privilege peer that can replace the parent may cause an out-of-root side effect before detection. Use native `require` mode for that threat model; see the [security model](security-model.md#symlinks-write-side).
32
+ 2. If `mkdir: true`, create missing parent directories relative to a pinned parent fd in the native path, or with per-component identity guards in the JavaScript fallback. When `denyMutations` or an explicit `mutationSymlinks` policy is present, each missing POSIX-native component is authorized before creation and its opened descriptor is authorized again before descent.
33
+ 3. Pin or guard the parent directory for the selected mechanism. Native operations use a parent fd and reapply configured mutation policy to the actual canonical destination selected by that descriptor; the pinned JavaScript fallback verifies directory identity before and after mutation and performs the same policy revalidation before its pathname dispatch. The JavaScript check cannot make the intervening pathname syscall atomic, so a same-privilege peer that can replace the parent may cause an out-of-root side effect before detection. Use native `require` mode for that threat model; see the [security model](security-model.md#symlinks-write-side).
34
34
  4. Write data to a sibling temp file in the same directory.
35
35
  5. Atomically rename the temp file over the destination.
36
36
  6. Stat the resulting fd and verify identity.
@@ -74,7 +74,7 @@ await fs.write(".env", "x"); // throws FsSafeError code "denied-path"
74
74
  await fs.remove(".ssh/id_rsa"); // throws FsSafeError code "denied-path"
75
75
  ```
76
76
 
77
- `paths` blocks exact absolute paths. `prefixes` blocks absolute paths and everything below them. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a mutation through a symlinked ancestor to a denied path is still denied. Root-level and per-call policies are additive; per-call policy can add denies, but cannot clear root defaults.
77
+ `paths` blocks exact absolute paths. `prefixes` blocks absolute paths and everything below them. For POSIX pinned `write`, `create`, and `copyIn`, an existing exact-path directory does not implicitly deny a mutation to its descendants, but creating a missing directory at that exact path is itself denied. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a mutation through a symlinked ancestor to a denied path is still denied. Root-level and per-call policies are additive; per-call policy can add denies, but cannot clear root defaults. Those POSIX pinned operations snapshot the merged policy before their first path observation, then authorize the actual descriptor-selected parent before staging or publication. A caller mutation of the original arrays, or a contained parent redirect after preflight, cannot clear that admission check.
78
78
 
79
79
  ## Write verbs
80
80
 
@@ -268,16 +268,35 @@ await fs.move("incoming/foo.txt", "archive/foo.txt", { overwrite: true });
268
268
 
269
269
  Both `from` and `to` are bounded; `..` in either is rejected.
270
270
 
271
- The JavaScript fallback checks both parent directories before and after the
272
- rename. A failed post-operation check rejects even though the rename may
273
- already have completed; rejection does not imply rollback.
271
+ The default no-clobber mode requires the native helper. It admits both parent
272
+ directory descriptors and performs a descriptor-relative no-replace rename, so
273
+ a competitor that creates the target first is preserved and the source remains
274
+ in place. If the helper or safe parent admission is unavailable, the call fails
275
+ with `helper-unavailable`; it never falls back to a check followed by a
276
+ replacing rename. After dispatch it rechecks both parent identities, so a
277
+ post-operation rejection can mean the no-replace rename completed. Directory
278
+ moves continue to require `overwrite: true`.
279
+
280
+ Both selected canonical endpoints are admitted inside the retained Root after
281
+ native parent admission. With `mutationSymlinks: "reject"`, both full operation
282
+ paths are rechecked after the live mutation-authority callback and before
283
+ dispatch. The Root and retained parents are fenced again after any such callback.
284
+ These checks retain the documented final check-to-syscall race.
285
+
286
+ For `{ overwrite: true }`, the JavaScript path checks both parent directories
287
+ before and after the rename. A failed post-operation check rejects even though
288
+ the rename may already have completed; rejection does not imply rollback.
274
289
 
275
290
  ### `fs.remove(rel)`
276
291
 
277
292
  Unlink a file or `rmdir` an empty directory. Non-empty directories throw `not-empty`. For atomic directory replacement, use [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic).
278
293
 
279
- The JavaScript fallback reports failed parent-directory checks after removal.
280
- The entry may already have been removed when this verification rejects.
294
+ Before a nonrecursive JavaScript fallback removal, fs-safe retains exact
295
+ Root-to-parent directory identities, with canonical endpoint checks at Root and
296
+ the immediate parent. It rejects a parent redirected through a symlink or
297
+ junction before `unlink` or `rmdir`, even with `force: true`, and rechecks the
298
+ retained ancestry after the operation settles. The entry may already have been
299
+ removed when that final verification rejects.
281
300
 
282
301
  ```ts
283
302
  await fs.remove("logs/yesterday.log");
@@ -426,6 +445,13 @@ opens, including for mode `0o200` files; replacement truncation happens only
426
445
  after type, identity, and boundary checks pass. Rejected existing paths are
427
446
  never cleanup-owned or unlinked.
428
447
 
448
+ Identity admission uses bigint descriptor and pathname receipts even though the
449
+ public `stat` field remains a numeric Node `Stats` object. Windows retries an
450
+ unknown device or file index once while retaining known components, then rejects
451
+ persistent ambiguity. If admission of a newly created file fails, cleanup only
452
+ unlinks a pathname that still has the exact created identity; rounded aliases,
453
+ symlinks, and unknown identities are preserved.
454
+
429
455
  ## Write defaults vs per-call options
430
456
 
431
457
  Set `mkdir: true` once on `root()`; pass text encodings per call when needed:
@@ -507,6 +533,10 @@ await fs.write("state.json", body); // succeeds on rclone FUSE
507
533
 
508
534
  **Security note.** `verify-content-with-lock` proves that the bytes observed after rename match the requested write and prevents *cooperating* writers from interleaving. It does **not** prove that the destination still names the temp-file object, retain fd-relative parent pinning, or stop a same-UID process that ignores the advisory lock. Do not use this option on directories writable by untrusted same-UID processes. Strict identity verification remains the default.
509
535
 
536
+ Windows `Root.write()` and `Root.writeJson()` honor this policy both as a Root default and as a per-call option. The Windows buffered writer acquires the same Root compatibility lock before creating parents, placeholders, or content. It keeps staging writable until publication, then applies the final mode through the retained destination descriptor. When content verification accepts a changed rename identity, that destination remains pinned through file sync, parent sync, and final strict identity checks.
537
+
538
+ The Windows buffered compatibility path resolves permitted in-root aliases before choosing its lock and binds publication to that effective destination. With the existing lock protocol, effective path components beneath the Root must contain only lower-case ASCII letters, digits, `.`, `_`, or `-`, with no trailing `.`. Unsupported spellings, including missing upper-case or non-ASCII names, fail with `path-alias` before mutation; no filesystem case-sensitivity or Unicode-folding behavior is guessed. This restriction does not apply to strict writes. Opaque Windows pathname identities still use strict verification against the retained original descriptor: they never, by themselves, authorize content-based acceptance of a replacement.
539
+
510
540
  Lock recovery is fail-closed. If a process crashes and leaves the root-level `.fs-safe-write-<sha256>.lock`, a later write reports the stale lock instead of deleting it based on a host-local PID. Recover only under external authority that excludes every competing writer; see [File lock](sidecar-lock.md#stale-recovery-guarded-remove-if-unchanged).
511
541
 
512
542
  ## See also
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -167,18 +167,18 @@
167
167
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
168
168
  },
169
169
  "optionalDependencies": {
170
- "@openclaw/fs-safe-darwin-arm64": "0.12.0",
171
- "@openclaw/fs-safe-darwin-x64": "0.12.0",
172
- "@openclaw/fs-safe-linux-arm64-gnu": "0.12.0",
173
- "@openclaw/fs-safe-linux-arm64-musl": "0.12.0",
174
- "@openclaw/fs-safe-linux-x64-gnu": "0.12.0",
175
- "@openclaw/fs-safe-linux-x64-musl": "0.12.0",
176
- "@openclaw/fs-safe-win32-x64-msvc": "0.12.0",
170
+ "@openclaw/fs-safe-darwin-arm64": "0.13.1",
171
+ "@openclaw/fs-safe-darwin-x64": "0.13.1",
172
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.13.1",
173
+ "@openclaw/fs-safe-linux-arm64-musl": "0.13.1",
174
+ "@openclaw/fs-safe-linux-x64-gnu": "0.13.1",
175
+ "@openclaw/fs-safe-linux-x64-musl": "0.13.1",
176
+ "@openclaw/fs-safe-win32-x64-msvc": "0.13.1",
177
177
  "jszip": "^3.10.2"
178
178
  },
179
179
  "devDependencies": {
180
180
  "@emnapi/runtime": "2.0.0-alpha.5",
181
- "@napi-rs/cli": "3.9.0",
181
+ "@napi-rs/cli": "3.9.1",
182
182
  "@types/node": "^26.5.1",
183
183
  "@vitest/coverage-v8": "5.0.0",
184
184
  "fast-check": "^4.9.0",
@@ -188,7 +188,7 @@
188
188
  "sigstore": "5.0.0",
189
189
  "tar": "7.5.22",
190
190
  "typescript": "^7.0.2",
191
- "vite": "8.2.2",
191
+ "vite": "8.3.0",
192
192
  "vitest": "^5.0.0"
193
193
  },
194
194
  "engines": {