@openclaw/fs-safe 0.4.7 → 0.5.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 (239) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +82 -43
  3. package/dist/archive-entry.d.ts.map +1 -1
  4. package/dist/archive-entry.js +5 -4
  5. package/dist/archive-errors.d.ts +11 -0
  6. package/dist/archive-errors.d.ts.map +1 -0
  7. package/dist/archive-errors.js +16 -0
  8. package/dist/archive-input.d.ts +12 -0
  9. package/dist/archive-input.d.ts.map +1 -0
  10. package/dist/archive-input.js +72 -0
  11. package/dist/archive-kind.d.ts +1 -1
  12. package/dist/archive-kind.d.ts.map +1 -1
  13. package/dist/archive-kind.js +18 -0
  14. package/dist/archive-limits.d.ts +10 -0
  15. package/dist/archive-limits.d.ts.map +1 -1
  16. package/dist/archive-limits.js +18 -0
  17. package/dist/archive-native.d.ts +18 -0
  18. package/dist/archive-native.d.ts.map +1 -0
  19. package/dist/archive-native.js +115 -0
  20. package/dist/archive-options.d.ts +21 -0
  21. package/dist/archive-options.d.ts.map +1 -0
  22. package/dist/archive-options.js +1 -0
  23. package/dist/archive-policy.d.ts +20 -0
  24. package/dist/archive-policy.d.ts.map +1 -0
  25. package/dist/archive-policy.js +29 -0
  26. package/dist/archive-read.d.ts +6 -0
  27. package/dist/archive-read.d.ts.map +1 -0
  28. package/dist/archive-read.js +200 -0
  29. package/dist/archive-staging.d.ts +2 -5
  30. package/dist/archive-staging.d.ts.map +1 -1
  31. package/dist/archive-staging.js +19 -10
  32. package/dist/archive-tar-meta.d.ts +6 -0
  33. package/dist/archive-tar-meta.d.ts.map +1 -0
  34. package/dist/archive-tar-meta.js +143 -0
  35. package/dist/archive-tar-runtime.d.ts +43 -0
  36. package/dist/archive-tar-runtime.d.ts.map +1 -0
  37. package/dist/archive-tar-runtime.js +8 -0
  38. package/dist/archive-tar.d.ts +5 -1
  39. package/dist/archive-tar.d.ts.map +1 -1
  40. package/dist/archive-tar.js +24 -6
  41. package/dist/archive-zip-entry.d.ts +15 -0
  42. package/dist/archive-zip-entry.d.ts.map +1 -0
  43. package/dist/archive-zip-entry.js +17 -0
  44. package/dist/archive.d.ts +7 -17
  45. package/dist/archive.d.ts.map +1 -1
  46. package/dist/archive.js +111 -108
  47. package/dist/atomic.d.ts +1 -1
  48. package/dist/atomic.d.ts.map +1 -1
  49. package/dist/config.d.ts +1 -1
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/config.js +1 -1
  52. package/dist/containment.d.ts +2 -0
  53. package/dist/containment.d.ts.map +1 -0
  54. package/dist/containment.js +1 -0
  55. package/dist/durability.d.ts +2 -0
  56. package/dist/durability.d.ts.map +1 -1
  57. package/dist/durability.js +2 -0
  58. package/dist/errors.d.ts +4 -1
  59. package/dist/errors.d.ts.map +1 -1
  60. package/dist/errors.js +2 -0
  61. package/dist/file-hash.d.ts +10 -0
  62. package/dist/file-hash.d.ts.map +1 -0
  63. package/dist/file-hash.js +73 -0
  64. package/dist/file-lock-sync.d.ts +35 -0
  65. package/dist/file-lock-sync.d.ts.map +1 -0
  66. package/dist/file-lock-sync.js +233 -0
  67. package/dist/file-lock.d.ts +2 -0
  68. package/dist/file-lock.d.ts.map +1 -1
  69. package/dist/file-lock.js +1 -0
  70. package/dist/filename.d.ts.map +1 -1
  71. package/dist/filename.js +4 -1
  72. package/dist/guarded-mkdir.d.ts +7 -1
  73. package/dist/guarded-mkdir.d.ts.map +1 -1
  74. package/dist/guarded-mkdir.js +45 -3
  75. package/dist/index.d.ts +4 -3
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +1 -1
  78. package/dist/json-document-store.d.ts.map +1 -1
  79. package/dist/json-document-store.js +30 -15
  80. package/dist/move-path.d.ts.map +1 -1
  81. package/dist/move-path.js +101 -17
  82. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  83. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  84. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  85. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  86. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  87. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  88. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
  89. package/dist/native-binding.d.ts +79 -0
  90. package/dist/native-binding.d.ts.map +1 -0
  91. package/dist/native-binding.js +1 -0
  92. package/dist/native-config.d.ts +17 -0
  93. package/dist/native-config.d.ts.map +1 -0
  94. package/dist/native-config.js +71 -0
  95. package/dist/native-operations.d.ts +21 -0
  96. package/dist/native-operations.d.ts.map +1 -0
  97. package/dist/native-operations.js +115 -0
  98. package/dist/native-pinned-write.d.ts +5 -0
  99. package/dist/native-pinned-write.d.ts.map +1 -0
  100. package/dist/native-pinned-write.js +115 -0
  101. package/dist/native.d.ts +14 -0
  102. package/dist/native.d.ts.map +1 -0
  103. package/dist/native.js +204 -0
  104. package/dist/output-sibling.d.ts +8 -0
  105. package/dist/output-sibling.d.ts.map +1 -0
  106. package/dist/output-sibling.js +122 -0
  107. package/dist/output.d.ts +2 -0
  108. package/dist/output.d.ts.map +1 -1
  109. package/dist/output.js +32 -5
  110. package/dist/owner-dacl.d.ts +31 -0
  111. package/dist/owner-dacl.d.ts.map +1 -0
  112. package/dist/owner-dacl.js +27 -0
  113. package/dist/path-policy.js +1 -1
  114. package/dist/permissions-public.d.ts +2 -0
  115. package/dist/permissions-public.d.ts.map +1 -1
  116. package/dist/permissions-public.js +2 -0
  117. package/dist/permissions.d.ts.map +1 -1
  118. package/dist/permissions.js +6 -0
  119. package/dist/pinned-operation.d.ts +2 -0
  120. package/dist/pinned-operation.d.ts.map +1 -0
  121. package/dist/pinned-operation.js +35 -0
  122. package/dist/pinned-write.d.ts +3 -16
  123. package/dist/pinned-write.d.ts.map +1 -1
  124. package/dist/pinned-write.js +20 -85
  125. package/dist/private-directory.d.ts +5 -0
  126. package/dist/private-directory.d.ts.map +1 -0
  127. package/dist/private-directory.js +13 -0
  128. package/dist/private-temp-workspace.d.ts +6 -2
  129. package/dist/private-temp-workspace.d.ts.map +1 -1
  130. package/dist/private-temp-workspace.js +40 -13
  131. package/dist/publish-file-failure.d.ts +27 -0
  132. package/dist/publish-file-failure.d.ts.map +1 -0
  133. package/dist/publish-file-failure.js +21 -0
  134. package/dist/publish-file.d.ts +21 -0
  135. package/dist/publish-file.d.ts.map +1 -0
  136. package/dist/publish-file.js +351 -0
  137. package/dist/read-opened-file.d.ts +3 -0
  138. package/dist/read-opened-file.d.ts.map +1 -1
  139. package/dist/read-opened-file.js +1 -0
  140. package/dist/replace-file-copy-fallback.d.ts +34 -0
  141. package/dist/replace-file-copy-fallback.d.ts.map +1 -0
  142. package/dist/replace-file-copy-fallback.js +339 -0
  143. package/dist/replace-file.d.ts +6 -2
  144. package/dist/replace-file.d.ts.map +1 -1
  145. package/dist/replace-file.js +39 -90
  146. package/dist/root-context.d.ts +4 -1
  147. package/dist/root-context.d.ts.map +1 -1
  148. package/dist/root-context.js +20 -1
  149. package/dist/root-impl.d.ts +5 -0
  150. package/dist/root-impl.d.ts.map +1 -1
  151. package/dist/root-impl.js +128 -225
  152. package/dist/root-path-existing.d.ts +3 -0
  153. package/dist/root-path-existing.d.ts.map +1 -0
  154. package/dist/root-path-existing.js +69 -0
  155. package/dist/root-path.d.ts +1 -2
  156. package/dist/root-path.d.ts.map +1 -1
  157. package/dist/root-path.js +67 -81
  158. package/dist/root-walk.d.ts +41 -0
  159. package/dist/root-walk.d.ts.map +1 -0
  160. package/dist/root-walk.js +114 -0
  161. package/dist/root.d.ts +2 -0
  162. package/dist/root.d.ts.map +1 -1
  163. package/dist/secret-file.d.ts +5 -2
  164. package/dist/secret-file.d.ts.map +1 -1
  165. package/dist/secret-file.js +20 -2
  166. package/dist/secret-read-async.d.ts +4 -0
  167. package/dist/secret-read-async.d.ts.map +1 -0
  168. package/dist/secret-read-async.js +95 -0
  169. package/dist/secret.d.ts +2 -1
  170. package/dist/secret.d.ts.map +1 -1
  171. package/dist/secret.js +2 -1
  172. package/dist/sibling-temp.d.ts.map +1 -1
  173. package/dist/sibling-temp.js +1 -0
  174. package/dist/sidecar-lock-handle.d.ts +20 -0
  175. package/dist/sidecar-lock-handle.d.ts.map +1 -0
  176. package/dist/sidecar-lock-handle.js +28 -0
  177. package/dist/sidecar-lock-policy.d.ts +10 -0
  178. package/dist/sidecar-lock-policy.d.ts.map +1 -0
  179. package/dist/sidecar-lock-policy.js +29 -0
  180. package/dist/sidecar-lock-reclaim.d.ts +22 -6
  181. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  182. package/dist/sidecar-lock-reclaim.js +106 -20
  183. package/dist/sidecar-lock-types.d.ts +56 -0
  184. package/dist/sidecar-lock-types.d.ts.map +1 -0
  185. package/dist/sidecar-lock-types.js +1 -0
  186. package/dist/sidecar-lock.d.ts +2 -45
  187. package/dist/sidecar-lock.d.ts.map +1 -1
  188. package/dist/sidecar-lock.js +94 -55
  189. package/dist/temp-cleanup.d.ts +7 -1
  190. package/dist/temp-cleanup.d.ts.map +1 -1
  191. package/dist/temp-cleanup.js +35 -5
  192. package/dist/temp.d.ts +2 -1
  193. package/dist/temp.d.ts.map +1 -1
  194. package/dist/test-hooks.d.ts +3 -0
  195. package/dist/test-hooks.d.ts.map +1 -1
  196. package/dist/windows-permissions-native.d.ts +9 -0
  197. package/dist/windows-permissions-native.d.ts.map +1 -0
  198. package/dist/windows-permissions-native.js +31 -0
  199. package/docs/archive.md +131 -13
  200. package/docs/atomic.md +30 -1
  201. package/docs/config.md +51 -21
  202. package/docs/durability.md +211 -1
  203. package/docs/errors.md +36 -6
  204. package/docs/filename.md +0 -0
  205. package/docs/index.md +11 -8
  206. package/docs/install.md +27 -26
  207. package/docs/json-store.md +18 -3
  208. package/docs/migrating-to-0.5.md +196 -0
  209. package/docs/native-helper.md +85 -0
  210. package/docs/native.md +134 -0
  211. package/docs/output.md +37 -11
  212. package/docs/path.md +1 -1
  213. package/docs/permissions.md +84 -1
  214. package/docs/quickstart.md +5 -2
  215. package/docs/reading.md +4 -4
  216. package/docs/root.md +31 -15
  217. package/docs/secret-file.md +50 -1
  218. package/docs/security-model.md +29 -8
  219. package/docs/sidecar-lock.md +92 -7
  220. package/docs/temp.md +24 -1
  221. package/docs/test-hooks.md +22 -2
  222. package/docs/testing.md +18 -7
  223. package/docs/types.md +3 -1
  224. package/docs/walk.md +55 -0
  225. package/docs/writing.md +3 -3
  226. package/package.json +8 -2
  227. package/dist/pinned-helper.d.ts +0 -9
  228. package/dist/pinned-helper.d.ts.map +0 -1
  229. package/dist/pinned-helper.js +0 -19
  230. package/dist/pinned-path.d.ts +0 -7
  231. package/dist/pinned-path.d.ts.map +0 -1
  232. package/dist/pinned-path.js +0 -21
  233. package/dist/pinned-python-config.d.ts +0 -9
  234. package/dist/pinned-python-config.d.ts.map +0 -1
  235. package/dist/pinned-python-config.js +0 -38
  236. package/dist/pinned-python.d.ts +0 -12
  237. package/dist/pinned-python.d.ts.map +0 -1
  238. package/dist/pinned-python.js +0 -688
  239. package/docs/python-helper.md +0 -103
package/docs/native.md ADDED
@@ -0,0 +1,134 @@
1
+ ---
2
+ title: Native architecture
3
+ description: "The bundled native bindings, fd-relative beneath model, platform mechanisms, loader security, and JavaScript fallback contract."
4
+ ---
5
+
6
+ # Native architecture
7
+
8
+ `@openclaw/fs-safe` bundles native bindings that supply mechanisms Node does
9
+ not expose directly. The Rust layer is deliberately not a second policy engine.
10
+ TypeScript owns trusted-root selection, path validation, archive filtering,
11
+ budgets, modes, identity fencing, cleanup decisions, and error normalization.
12
+ Rust receives already-decided relative operations and performs the smallest
13
+ platform syscall sequence that can preserve the boundary.
14
+
15
+ Every operation that has an equivalent safe Node implementation keeps that
16
+ guarded JavaScript path. Native loading is lazy; installs do not compile Rust,
17
+ run postinstall code, or fetch binaries. The npm tarball carries all seven
18
+ supported targets, so it is larger than a per-platform package by design.
19
+ Native-only formats and creation-time Windows DACL guarantees fail explicitly
20
+ instead of substituting a weaker implementation.
21
+
22
+ ## The beneath model
23
+
24
+ A trusted directory descriptor is the capability. Native operations accept
25
+ that descriptor plus a validated relative path and never reconstruct authority
26
+ from a process working directory. Newly created files use exclusive creation,
27
+ and TypeScript compares descriptor, pathname, and expected identities before
28
+ accepting results.
29
+
30
+ Conceptually, a caller grants authority to an already-open root—not to a path
31
+ string that can be reinterpreted later:
32
+
33
+ ```text
34
+ validated Root handle
35
+ └─ relative components (untrusted)
36
+ └─ open/link/mkdir beneath the handle
37
+ └─ compare descriptor + pathname + expected identity
38
+ ```
39
+
40
+ The TypeScript layer validates and decides. The native layer never decides
41
+ whether a path, archive entry, mode, owner, or cleanup policy is acceptable.
42
+
43
+ - Linux uses `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`, fd-relative
44
+ `mkdirat`/`linkat`/`renameat2`, `FICLONE`, and `copy_file_range`.
45
+ - macOS 15.4 and newer first use `openat(O_RESOLVE_BENEATH)`; older kernels walk
46
+ components with `openat(O_NOFOLLOW)` and restart in-root symlinks from the
47
+ pinned root. Both routes apply an `F_GETPATH` post-open containment detector,
48
+ but directory rename races mean the result remains `best-effort`, not
49
+ race-atomic. macOS uses `renameatx_np(RENAME_EXCL)` and permits
50
+ `fclonefileat` in an owned, non-shared parent. The clone is normalized inside
51
+ a private staging directory: flags, ACLs, extended attributes, and broad mode
52
+ bits are cleared before no-replace publication.
53
+ - Windows uses handle-relative `NtCreateFile` with `OBJ_DONT_REPARSE` and
54
+ `FILE_OPEN_REPARSE_POINT`, then explicitly rejects reparse points. Rename and
55
+ hardlink operations stay rooted in already-open handles. Owner/DACL reads
56
+ use `GetSecurityInfo`; private directories receive their protected DACL in
57
+ the `CreateDirectoryW` call itself.
58
+
59
+ ## Archives
60
+
61
+ Rust streams ZIP and TAR payloads, including gzip, zstd, and bzip2. It first
62
+ returns a bounded manifest. TypeScript applies the shared path, filter, strip,
63
+ mode, and byte policies and returns an index-bound extraction plan. Rust then
64
+ creates only those planned entries beneath a private staging descriptor.
65
+
66
+ A fixed-512-byte pass-through meter sits between decompression and the TAR
67
+ crate. It reads only header type and octal/base-256 size fields. It never parses
68
+ metadata content. Oversized GNU long-name/link metadata is rejected before
69
+ buffering; PAX size overrides and GNU sparse entries are rejected as
70
+ unmeterable rather than guessed. The JavaScript node-tar path receives the same
71
+ `maxMetaEntryBytes` value and a matching fixed-header preflight.
72
+
73
+ ## Publication and hashing
74
+
75
+ Exclusive publication tries a hardlink, then a copy-on-write clone, Linux
76
+ `copy_file_range`, and finally the existing asynchronous JavaScript byte loop.
77
+ All routes preserve `wx` semantics and the same source/target identity and
78
+ SHA-256 fencing. Native hashing and Linux whole-file copying run on N-API async
79
+ workers rather than the JavaScript event loop.
80
+
81
+ ## Mode semantics
82
+
83
+ | Mode | Native loading | Fallback |
84
+ |---|---|---|
85
+ | `auto` | Try once, cache the result | Use guarded JavaScript when unavailable |
86
+ | `require` | Try once, cache the result | Throw `FsSafeError("helper-unavailable")` |
87
+ | `off` | Never attempt a binding load | Always use guarded JavaScript |
88
+
89
+ The one exception is functionality with no safe JavaScript implementation:
90
+ zstd/bzip2 TAR and Windows private-directory creation fail with
91
+ `helper-unavailable` when native support is absent or off.
92
+
93
+ ## JavaScript fallback guarantees and delta
94
+
95
+ Public policy does not change with the selected mechanism: traversal and link
96
+ rejection, archive filters/limits/modes, exclusive target creation, source and
97
+ target identity fencing, publication cleanup receipts, and secret/lock policy
98
+ remain TypeScript-owned. What changes is the syscall strength or availability:
99
+
100
+ | Capability | Native path | Guarded JavaScript path |
101
+ |---|---|---|
102
+ | Root-relative opens/mutations | Descriptor-relative beneath operations. Linux reports `kernel-atomic`; macOS and Windows report `best-effort`. macOS uses `O_RESOLVE_BENEATH` when available plus an `F_GETPATH` detector, while Windows rejects reparse traversal in the object-manager call. | Reports `best-effort`: component-wise alias checks, no-follow opens where Node exposes them, private temp/rename, and post-operation identity verification. A hostile same-UID peer has a wider pathname race window. |
103
+ | ZIP/TAR/gzip | Rust streaming decode and fd-relative output creation. | JSZip/node-tar into a private stage, then the same guarded merge policy. |
104
+ | Zstd/bzip2 TAR | Supported. | Unsupported; typed `helper-unavailable`. |
105
+ | Publication copy | Clone, Linux `copy_file_range`, async native SHA-256. | Exclusive `wx` byte loop and Node SHA-256 with the same content/identity fences. |
106
+ | `rename-noreplace` | Atomic platform no-replace rename. | Unsupported; no emulation by check-then-rename. |
107
+ | Windows DACL read | Direct `GetSecurityInfo`; the public facts API exposes ordered basic allow/deny ACE SIDs, masks, and decoded flags without trust policy. | Established .NET/`icacls` inspection fallback for coarse permission checks; raw ACE facts are native-only. |
108
+ | Windows private directory | Creation-time protected DACL. | Unsupported; no weaker pathname-only substitute. |
109
+
110
+ Use `off` in CI to keep the fallback contract exercised. Use `require` when a
111
+ deployment depends on the stronger mechanism or a native-only feature; do not
112
+ infer native loading from timing.
113
+
114
+ ## Loader security
115
+
116
+ Importing fs-safe never executes a child process. Linux libc selection uses
117
+ the Node process report, conventional musl library filenames, and the ELF
118
+ `PT_INTERP` field of `process.execPath`. If all probes are inconclusive, the
119
+ loader conservatively attempts the bundled glibc binary and lets normal module
120
+ loading fail into `auto` fallback. The loader requires only
121
+ `dist/native/<target>/fs-safe-native.node`; it never probes optional packages,
122
+ downloads code, or runs a postinstall step. A missing or incompatible binary
123
+ silently selects the JavaScript fallback in `auto`, throws typed
124
+ `helper-unavailable` in `require`, and is never inspected in `off`. Tests reject
125
+ `child_process`, `exec`, or `spawn` usage in the loader.
126
+
127
+ ## Related pages
128
+
129
+ - [Native helper policy](native-helper.md)
130
+ - [Security model](security-model.md)
131
+ - [Archive extraction](archive.md)
132
+ - [Durability](durability.md)
133
+ - [Permissions](permissions.md)
134
+ - [Migrating to 0.5](migrating-to-0.5.md)
package/docs/output.md CHANGED
@@ -16,10 +16,10 @@ await writeExternalFileWithinRoot({
16
16
  });
17
17
  ```
18
18
 
19
- The external writer never receives the final destination path. It receives a
20
- private temp file path instead. After the callback returns, fs-safe copies that
21
- staged file into the requested target through the same root boundary used by
22
- `Root.copyIn()`.
19
+ The external writer receives a staged path instead of the final destination.
20
+ The default private-workspace mode finalizes through `Root.copyIn()`. An
21
+ opt-in sibling mode stages in the destination directory and atomically renames
22
+ the completed file over the target.
23
23
 
24
24
  ## Signature
25
25
 
@@ -34,6 +34,8 @@ type ExternalFileWriteOptions<T = void> = {
34
34
  write: (filePath: string) => Promise<T>;
35
35
  maxBytes?: number;
36
36
  mode?: number;
37
+ staging?: "workspace" | "sibling"; // default: "workspace"
38
+ fallbackFileName?: string; // safe staged-name fallback
37
39
  };
38
40
 
39
41
  type ExternalFileWriteResult<T = void> = {
@@ -46,18 +48,42 @@ The requested `path` must name a file. Missing destination parents are created
46
48
  by the helper because the operation is "produce this output file under the
47
49
  root"; callers should choose the filename before calling this API.
48
50
 
49
- Use `maxBytes` when the external producer can create arbitrarily large files.
50
- Use `mode` when the finalized file needs a specific POSIX mode. Both are
51
- enforced during the `Root.copyIn()` finalization step, after the external writer
52
- has produced the staged file and before the final target is committed.
51
+ Use `maxBytes` when the external producer can create arbitrarily large files,
52
+ and `mode` when the finalized file needs a specific POSIX mode. Both staging
53
+ modes enforce them after the producer returns and before committing the target.
54
+ Requested basenames containing C0/C1 controls or Windows-invalid characters are
55
+ sanitized portably; `fallbackFileName` supplies the name when nothing remains.
56
+ The same sanitized basename is used for producer staging, guarded internal
57
+ temps, the final rename target, and the returned `path`; raw and staged names
58
+ never diverge.
59
+
60
+ ## Choosing a staging mode
61
+
62
+ `staging: "workspace"` is the default. The producer writes in private temp
63
+ storage, then fs-safe copies through the guarded root boundary. Choose it when
64
+ the temp and destination filesystems may differ, or when an externally produced
65
+ partial file must never appear in the destination directory. The final target
66
+ still appears only after guarded finalization.
67
+
68
+ `staging: "sibling"` gives the producer a randomized temp path in the target
69
+ directory. Choose it only when that directory itself is the approved writable
70
+ boundary and same-filesystem atomic replacement is required. After the callback
71
+ returns, fs-safe pins and validates the staged regular file, rejects hardlinks
72
+ and size-limit violations, applies `mode`, fsyncs it, and atomically renames it
73
+ over the target. Existing files and symlink entries are replaced without
74
+ following their contents or referents. The parent identity is guarded across
75
+ the operation and the parent directory is synchronized best-effort after
76
+ rename.
53
77
 
54
78
  ## Why not pass the final path to the library?
55
79
 
56
80
  If a target parent can be swapped after validation, handing an external library
57
81
  the final path can make the library write outside the intended root before
58
- fs-safe has a chance to finalize or reject the operation. This helper stages in
59
- a private temp workspace first, then finalizes with `Root.copyIn()`. That keeps
60
- the trust-boundary write inside fs-safe's root-aware copy/atomic-write path.
82
+ fs-safe has a chance to finalize or reject the operation. Workspace staging
83
+ keeps the trust-boundary write inside fs-safe's root-aware copy/atomic-write
84
+ path. Sibling staging intentionally shifts the writable boundary to the
85
+ destination directory, while keeping pathname validation, staged-file identity
86
+ checks, and the final rename under fs-safe's control.
61
87
 
62
88
  ## Browser download example
63
89
 
package/docs/path.md CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  } from "@openclaw/fs-safe/path";
20
20
  ```
21
21
 
22
- Only `root()`, `FsSafeError`, and the Python helper config live on the main entry. Path helpers are deliberately a subpath import so the main entry stays small.
22
+ Only `root()`, `FsSafeError`, and the native helper config live on the main entry. Path helpers are deliberately a subpath import so the main entry stays small.
23
23
 
24
24
  ## Boundary checks
25
25
 
@@ -64,7 +64,88 @@ createIcaclsResetCommand(targetPath, { isDir, env });
64
64
  resolveWindowsUserPrincipal(env);
65
65
  ```
66
66
 
67
- The default Windows inspector calls `icacls.exe /sid` and classifies principals as trusted, world, or group. Trusted defaults include the current user, SYSTEM, and Administrators. The parser is on the advanced surface so tests and CLIs can process captured `icacls` output without spawning a process.
67
+ The fallback Windows inspector calls `icacls.exe <path>` using its supported
68
+ path-only inspection syntax and classifies principals as trusted, world, or
69
+ group. Trusted defaults include the current user, SYSTEM, and Administrators.
70
+ The parser is on the advanced surface so tests and CLIs can process captured
71
+ `icacls` output without spawning a process.
72
+
73
+ When the bundled native binding is available, `inspectPathPermissions()`
74
+ reads the owner and DACL directly with Windows security APIs. It classifies the
75
+ current user, LocalSystem, and built-in Administrators as trusted and reports
76
+ the world/group read/write facts consumed by secure reads. Descriptor forms it
77
+ cannot classify equivalently fall back to the established owner/.NET and
78
+ `icacls` path; `mode: "off"` exercises that fallback deterministically.
79
+
80
+ ## Policy-free owner and DACL facts
81
+
82
+ `readOwnerAndDacl()` exposes the direct Windows descriptor facts needed by a
83
+ consumer that owns a principal allowlist. It deliberately does not decide
84
+ which SID is trusted or calculate effective access. For example, snapshot
85
+ staging can reject an incomplete descriptor and ignore inherit-only ACEs before
86
+ applying its own exact SID policy:
87
+
88
+ ```ts
89
+ import { readOwnerAndDacl } from "@openclaw/fs-safe/permissions";
90
+
91
+ const facts = readOwnerAndDacl(stagingDirectory);
92
+ if (facts.status === "unsupported-platform") {
93
+ throw new Error(`Windows ACL facts unavailable on ${facts.platform}`);
94
+ }
95
+ if (!facts.isLocal || !facts.daclPresent || !facts.complete) {
96
+ throw new Error("staging DACL cannot be evaluated completely");
97
+ }
98
+
99
+ for (const ace of facts.aces) {
100
+ if (ace.flags.inheritOnly) continue;
101
+ if (!trustedSids.has(ace.sid)) {
102
+ throw new Error(`unexpected staging principal: ${ace.sid}`);
103
+ }
104
+ evaluateMaskAndDenyOrder(ace.aceType, ace.mask);
105
+ }
106
+ ```
107
+
108
+ On Windows the supported result contains `ownerSid`, `currentUserSid`,
109
+ `daclPresent`, `isLocal`, `complete`, `unsupportedAceTypes`, and ordered basic
110
+ allow/deny `aces`. `currentUserSid` is the process token's `TokenUser` SID, so
111
+ callers can compare it with the owner or their own allowlist without fs-safe
112
+ applying trust policy. Each ACE has `{ sid, mask, aceType, flags }`; `flags`
113
+ retains the raw byte and decoded
114
+ `objectInherit`, `containerInherit`, `noPropagateInherit`, `inheritOnly`,
115
+ `inherited`, `successfulAccess`, and `failedAccess` facts. SID strings are
116
+ lowercase Windows SID notation. `daclPresent: false` represents a null DACL,
117
+ which grants unrestricted access; it must not be mistaken for an empty DACL.
118
+
119
+ Object-specific and other ACE layouts are not guessed: they are omitted,
120
+ `complete` becomes false, and their numeric types appear in
121
+ `unsupportedAceTypes`, allowing a security-sensitive caller to fail closed.
122
+ Non-Windows systems return `{ status: "unsupported-platform", platform }`.
123
+ Windows requires the bundled native binding; if it is unavailable or forced
124
+ off, the call throws `FsSafeError("helper-unavailable")`. The existing coarse
125
+ `inspectPathPermissions()` API still owns its compatibility fallback and trust
126
+ classification.
127
+
128
+ ## Private directories
129
+
130
+ ```ts
131
+ import path from "node:path";
132
+ import { createPrivateDirectory } from "@openclaw/fs-safe/permissions";
133
+
134
+ const sqliteDirectory =
135
+ "C:\\Users\\me\\AppData\\Local\\OpenClaw\\private-databases";
136
+ await createPrivateDirectory(sqliteDirectory);
137
+ await openSqlite(path.join(sqliteDirectory, "sessions.sqlite"));
138
+ ```
139
+
140
+ On Windows with native support, this creates the directory and applies a
141
+ protected owner + LocalSystem + Administrators full-control DACL directly with
142
+ an atomic security descriptor; no PowerShell or `icacls` process is launched.
143
+ This API is Windows-only and native-only; it fails closed with
144
+ `FsSafeError("helper-unavailable")` on other platforms, when native mode is off,
145
+ or when the binding is unavailable. POSIX callers should create private
146
+ directories through their existing trusted-root creation policy rather than a
147
+ pathname-only compatibility shim. Existing Windows permission inspection still
148
+ retains its .NET/`icacls` compatibility fallback.
68
149
 
69
150
  Use `createIcaclsResetCommand()` when you need a structured command and argv pair. Use `formatIcaclsResetCommand()` when you only need a remediation string for a user-facing message.
70
151
 
@@ -96,3 +177,5 @@ type PermissionCheck = {
96
177
 
97
178
  - [Secure file reads](secure-file.md) — fd-pinned reads that enforce these checks.
98
179
  - [Errors](errors.md) — permission-related `FsSafeError` codes.
180
+ - [Native architecture](native.md) — direct Windows security descriptor mechanisms.
181
+ - [Migrating to 0.5](migrating-to-0.5.md) — native-only feature checklist.
@@ -67,7 +67,7 @@ const names = await fs.list("state"); // string[]
67
67
  const entries = await fs.list("state", { withFileTypes: true }); // DirEntry[]
68
68
  ```
69
69
 
70
- `exists`, `stat`, and `list` are boundary-checked but **do not pin a later operation** to the same filesystem object. For race-resistant reads or writes, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()` they pin the path identity at the point of use.
70
+ `exists`, `stat`, and `list` are boundary-checked but **do not pin a later operation** to the same filesystem object. For operation-local identity checks, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()`. Linux native beneath opens are kernel-atomic; other mechanisms remain best-effort as documented in the [security model](security-model.md#containment-guarantees-by-platform).
71
71
 
72
72
  ## 6. Catch escapes
73
73
 
@@ -121,7 +121,10 @@ await extractArchive({
121
121
  maxEntries: 50_000,
122
122
  maxExtractedBytes: 512 * 1024 * 1024,
123
123
  maxEntryBytes: 256 * 1024 * 1024,
124
+ maxMetaEntryBytes: 1024 * 1024,
125
+ maxEntryPathComponents: 64,
124
126
  },
127
+ entryModes: "clamp",
125
128
  });
126
129
  ```
127
130
 
@@ -133,7 +136,7 @@ Extraction stages into a private dir and merges through the same boundary used b
133
136
  import { withTempWorkspace } from "@openclaw/fs-safe/temp";
134
137
 
135
138
  await withTempWorkspace({ rootDir: "/srv/jobs/tmp", prefix: "build-" }, async (workspace) => {
136
- await fs.copyIn("input.bin", "/tmp/source.bin");
139
+ await workspace.copyIn("input.bin", "/tmp/source.bin");
137
140
  // ...do work in workspace.dir; auto-cleaned on exit
138
141
  });
139
142
  ```
package/docs/reading.md CHANGED
@@ -3,7 +3,7 @@
3
3
  The `Root` handle exposes five read shapes. Pick the narrowest one that gives you what you need — narrower shapes do less work and surface fewer footguns.
4
4
 
5
5
  ```ts
6
- const result = await fs.read("notes/today.txt"); // { buffer, realPath, stat }
6
+ const result = await fs.read("notes/today.txt"); // { buffer, containment, realPath, stat }
7
7
  const text = await fs.readText("notes/today.txt"); // string
8
8
  const bytes = await fs.readBytes("image.png"); // Buffer
9
9
  const json = await fs.readJson<Config>("config.json"); // T
@@ -30,7 +30,7 @@ Regardless of shape, every read goes through the same boundary checks:
30
30
  The full result. Use it when you need both the bytes and the verified `realPath` or `stat`:
31
31
 
32
32
  ```ts
33
- const { buffer, realPath, stat } = await fs.read("notes/today.txt");
33
+ const { buffer, containment, realPath, stat } = await fs.read("notes/today.txt");
34
34
  console.log(`${stat.size} bytes at ${realPath}`);
35
35
  ```
36
36
 
@@ -63,7 +63,7 @@ For tighter control over malformed-or-missing JSON, use the standalone helpers i
63
63
 
64
64
  ### `fs.open(rel, options?)`
65
65
 
66
- Returns a `FileHandle` plus the verified `realPath` and `stat`. Use this for streaming or partial reads, and **always close the handle**:
66
+ Returns a `FileHandle` plus `containment: "best-effort"`, the verified `realPath`, and `stat`. Use this for streaming or partial reads, and **always close the handle**:
67
67
 
68
68
  ```ts
69
69
  const opened = await fs.open("large.log");
@@ -121,7 +121,7 @@ if (await fs.exists("notes/today.txt")) {
121
121
  }
122
122
  ```
123
123
 
124
- A symlink swap between `exists` and `readText` is caught by the read; the boundary is per-call.
124
+ A symlink swap between `exists` and `readText` is checked again by the read; the boundary and its documented race window are per-call.
125
125
 
126
126
  ## Streaming patterns
127
127
 
package/docs/root.md CHANGED
@@ -44,15 +44,30 @@ Every method on the returned handle accepts paths relative to the root and rejec
44
44
  ### Reads
45
45
 
46
46
  ```ts
47
- fs.read(rel, options?) // { buffer, realPath, stat }
47
+ fs.read(rel, options?) // { buffer, containment, realPath, stat }
48
48
  fs.readBytes(rel, options?) // Buffer
49
49
  fs.readText(rel, options?) // string
50
50
  fs.readJson<T>(rel, options?) // parsed T
51
- fs.open(rel, options?) // { handle, realPath, stat, [Symbol.asyncDispose] }
51
+ fs.open(rel, options?) // { handle, containment, realPath, stat, [Symbol.asyncDispose] }
52
52
  fs.readAbsolute(absPath, options?) // ReadResult; absPath must already be inside the root
53
53
  fs.reader(options?) // (path) => Promise<Buffer>; useful for loader APIs
54
+ fs.walk(rel, options) // root-bounded AsyncIterable<{ relativePath, kind, size }>
54
55
  ```
55
56
 
57
+ `walk()` is the incremental, root-bounded recursive scan. It supports entry and
58
+ depth budgets, cancellation, and `symlinkPolicy: "skip" |
59
+ "follow-within-root"`. Budget exhaustion yields a `"truncated"` marker by
60
+ default or throws `FsSafeError("too-large")` with `limitBehavior: "throw"`.
61
+ Use `entryFilter(entry)` to return `"include"`, `"skip"`, or
62
+ `"skip-subtree"`. `"skip"` omits the current entry but still descends into a
63
+ directory; `"skip-subtree"` omits a directory and all of its descendants.
64
+ Directory reads remain fail-fast by default. With
65
+ `onDirectoryError: "skip-and-report"`, the iterator instead yields
66
+ `{ relativePath, kind: "directory-error", size: 0, error }` and continues with
67
+ the remaining tree.
68
+ See [Directory walking](walk.md) for the pure-Node guarantees and the contrast
69
+ with the standalone best-effort walkers.
70
+
56
71
  `open()` returns a Node `FileHandle` for streaming. Prefer `await using` for cleanup:
57
72
 
58
73
  ```ts
@@ -64,6 +79,10 @@ await using opened = await fs.open("large.log");
64
79
  }
65
80
  ```
66
81
 
82
+ `open()`, `read()`, and `openWritable()` results include
83
+ `containment: "best-effort"`. The field reports the mechanism used; see the
84
+ [security model](security-model.md#containment-guarantees-by-platform).
85
+
67
86
  ### Writes
68
87
 
69
88
  ```ts
@@ -100,25 +119,22 @@ fs.resolve(rel) // absolute path inside the root, after canonic
100
119
 
101
120
  These do not pin a later operation. They are safe to expose to UIs and decision points; for the actual read or write, use the verb methods so the operation pins identity at the point of use.
102
121
 
103
- ## Python helper mode
122
+ ## Native helper mode
104
123
 
105
- On POSIX, mutation and inspection methods that need fd-relative directory
106
- operations go through one persistent Python helper process. This avoids a
107
- spawn-per-call cost while still using `openat`/`renameat`/`unlinkat`-style
108
- operations that Node's `fs` API does not expose ergonomically.
124
+ Create-only writes prefer the bundled native helper for fd-relative opens and
125
+ atomic no-replace rename. Operations without native wiring retain their guarded
126
+ JavaScript implementations.
109
127
 
110
128
  ```ts
111
- import { configureFsSafePython } from "@openclaw/fs-safe/config";
129
+ import { configureFsSafeNative } from "@openclaw/fs-safe/config";
112
130
 
113
- configureFsSafePython({ mode: "off" }); // Node-only fallback path
114
- configureFsSafePython({ mode: "require" }); // fail if fd-relative helper unavailable
131
+ configureFsSafeNative({ mode: "off" }); // guarded JavaScript path
132
+ configureFsSafeNative({ mode: "require" }); // fail if the binding is unavailable
115
133
  ```
116
134
 
117
- `auto` is the default. Configure the mode before creating roots. Without the
118
- helper, root methods still run, but same-UID races that swap parent directories
119
- between validation and mutation are harder to close completely. Use `require`
120
- when that downgrade should be treated as a deployment failure. See
121
- [Python helper policy](python-helper.md) for deployment guidance.
135
+ `auto` is the default. Configure the mode before creating roots. See the
136
+ [native helper policy](native-helper.md) for supported platforms, the native
137
+ surface, and the precise fallback boundary.
122
138
 
123
139
  ### Properties
124
140
 
@@ -4,7 +4,10 @@ Helpers for reading and writing credentials. Files are written at mode `0o600`,
4
4
 
5
5
  ```ts
6
6
  import {
7
+ createSecretFileAtomic,
8
+ readSecretFile,
7
9
  readSecretFileSync,
10
+ tryReadSecretFile,
8
11
  tryReadSecretFileSync,
9
12
  writeSecretFileAtomic,
10
13
  DEFAULT_SECRET_FILE_MAX_BYTES,
@@ -54,7 +57,7 @@ if (token) {
54
57
  Strict reader. Throws `FsSafeError` when the file is missing, too large, empty, unreadable, or rejected by the validation checks. Use when failing loudly is the right call:
55
58
 
56
59
  ```ts
57
- const token = readSecretFileSync("/var/lib/app/auth.token");
60
+ const token = readSecretFileSync("/var/lib/app/auth.token", "auth token");
58
61
  ```
59
62
 
60
63
  ### Read options
@@ -69,6 +72,24 @@ type SecretFileReadOptions = {
69
72
 
70
73
  The reader trims the file content and rejects empty results. `rejectSymlink` blocks a symlink path before the pinned read. Hardlinks are rejected by default so another in-tree name cannot alias the credential; pass `rejectHardlinks: false` only when you explicitly trust that layout.
71
74
 
75
+ `readSecretFile()` and `tryReadSecretFile()` are asynchronous counterparts with
76
+ the same pinned-handle validation, byte cap, trimming, error codes, and strict
77
+ versus missing-is-undefined naming semantics.
78
+
79
+ Use the async strict reader when a service cannot start safely without the
80
+ credential:
81
+
82
+ ```ts
83
+ import { readSecretFile } from "@openclaw/fs-safe/secret";
84
+
85
+ const signingKey = await readSecretFile(
86
+ "/var/lib/app/keys/webhook-signing.key",
87
+ "webhook signing key",
88
+ { maxBytes: 8 * 1024, rejectSymlink: true },
89
+ );
90
+ startWebhookVerifier(signingKey);
91
+ ```
92
+
72
93
  ## Writing
73
94
 
74
95
  ### `writeSecretFileAtomic(params)`
@@ -99,6 +120,33 @@ type WriteSecretFileParams = {
99
120
 
100
121
  The directory mode is asserted on each component along the path: `rootDir`, then any intermediate dirs, then the parent. The helper enforces that every component matches `dirMode` — wider permissions on an existing directory cause the write to fail. Audit and tighten existing secret directories yourself.
101
122
 
123
+ ### `createSecretFileAtomic(params)`
124
+
125
+ This create-only sibling has the same directory, mode, pinned-write, and
126
+ post-write verification policy. Final materialization uses exclusive create;
127
+ if anything already occupies the target path it throws
128
+ `FsSafeError("secret-exists")` without modifying that entry. Use the distinct
129
+ name when first-writer-wins is part of the credential protocol.
130
+
131
+ For example, two onboarding requests may race to install the first refresh
132
+ token. Exactly one should win, and the loser must not overwrite it:
133
+
134
+ ```ts
135
+ import { FsSafeError } from "@openclaw/fs-safe/errors";
136
+ import { createSecretFileAtomic } from "@openclaw/fs-safe/secret";
137
+
138
+ try {
139
+ await createSecretFileAtomic({
140
+ rootDir: "/var/lib/app/credentials",
141
+ filePath: "/var/lib/app/credentials/provider.refresh-token",
142
+ content: refreshToken,
143
+ });
144
+ } catch (error) {
145
+ if (!(error instanceof FsSafeError) || error.code !== "secret-exists") throw error;
146
+ // Another initializer won. Read and validate the installed credential.
147
+ }
148
+ ```
149
+
102
150
  For more permissive credentials, override `mode`:
103
151
 
104
152
  ```ts
@@ -153,3 +201,4 @@ await withTimeout(
153
201
  - [JSON files](json.md) — `writeJson` accepts `mode: 0o600` for non-secret JSON state.
154
202
  - [Atomic writes](atomic.md) — the lower-level `replaceFileAtomic` used by these helpers.
155
203
  - [Private file-store mode](private-file-store.md) — root-bounded JSON+text stores using secret-file write policy.
204
+ - [Migrating to 0.5](migrating-to-0.5.md) — strict/try reads and create-only adoption checklist.
@@ -4,6 +4,19 @@
4
4
 
5
5
  The same shape exists in other languages: Go's [`os.Root` / `OpenInRoot`](https://go.dev/blog/osroot) and Rust's [`cap-std`](https://github.com/bytecodealliance/cap-std) both expose a root handle whose operations refuse to escape it. `fs-safe` is the Node-side equivalent: a single `root()` capability that carries the boundary across every read, write, move, and remove, instead of leaving each call site to redo `path.resolve(...).startsWith(...)` and hope.
6
6
 
7
+ ## Affected versions / exposure
8
+
9
+ In published releases through 0.4.7, the exported `resolveRootPath()` and
10
+ `resolveRootPathSync()` helpers validated a lexically normalized path spelling.
11
+ A caller-supplied path traversing an in-root symlink could therefore pass
12
+ validation while resolving outside the root. Version 0.5 fixes this with
13
+ component-wise alias resolution, resolving each alias before applying later
14
+ path components.
15
+
16
+ `root()` handles were **not** affected: their operations have contained this
17
+ case since `5ddca80`. Exposure is limited to consumers that call
18
+ `resolveRootPath()` or `resolveRootPathSync()` directly.
19
+
7
20
  ## Threat model
8
21
 
9
22
  You hand a `root()` boundary to a piece of code that takes caller-controlled relative paths. The library defends against a caller that:
@@ -31,7 +44,7 @@ If you need full sandboxing, run the worker under reduced privileges (uid, conta
31
44
 
32
45
  ### Path traversal and absolute paths
33
46
 
34
- Every relative path is resolved against the canonicalized real path of the root, then checked with `isPathInside`. Inputs containing `..`, leading `/` (without `pathScope` opt-in), or that resolve outside the root throw `outside-workspace`.
47
+ Every relative path is resolved against the canonicalized real path of the root, then checked with `isPathInside`. Alias resolution walks components before applying a later `..`, so a symlink cannot change what that parent segment means after validation. Parent traversal that escapes, leading `/` (without `pathScope` opt-in), or any canonical result outside the root throws `outside-workspace`.
35
48
 
36
49
  ### Symlinks (read side)
37
50
 
@@ -49,7 +62,7 @@ When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink >
49
62
 
50
63
  ### TOCTOU between resolve and use
51
64
 
52
- `resolve()`, `exists()`, `stat()`, and `list()` are explicitly **not** race-resistant — they answer a question and return. To act on a path with race resistance, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()`. They re-pin the path identity at the point of use.
65
+ `resolve()`, `exists()`, `stat()`, and `list()` are explicitly advisory — they answer a question and return. To act on a path with operation-local identity checks, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()`. The containment table below states which opens are kernel-atomic and which remain best-effort.
53
66
 
54
67
  ### Denied mutations
55
68
 
@@ -84,12 +97,20 @@ A library cannot revoke its own caller's authority. If your code chooses to bypa
84
97
 
85
98
  The library does not modify or constrain the global Node.js `fs` namespace, and it does not patch the runtime. Other code in the same process retains its normal filesystem authority.
86
99
 
87
- ## Platform notes
100
+ ## Containment guarantees by platform
101
+
102
+ `openBeneath()` and JavaScript open results report one of two factual containment classes:
103
+
104
+ | Mechanism | Reported containment | Boundary |
105
+ |---|---|---|
106
+ | Linux native | `kernel-atomic` | `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` resolves and opens under the root in one kernel operation. |
107
+ | macOS native | `best-effort` | macOS 15.4 and newer use `O_RESOLVE_BENEATH` first; older kernels use the guarded `openat(O_NOFOLLOW)` component walk. Both verify the opened descriptor with `F_GETPATH`. |
108
+ | Windows native | `best-effort` | Handle-relative `NtCreateFile` rejects reparse points, but this package does not claim a Linux-style atomic beneath guarantee. |
109
+ | JavaScript fallback | `best-effort` | Canonical checks, no-follow opens where Node exposes them, and post-open identity checks form a check-then-use sequence. |
88
110
 
89
- - **POSIX (Linux, macOS):** Best-defended path. Uses `O_NOFOLLOW`, fd identity checks, and one persistent Python helper process for fd-relative `unlinkat` / `mkdirat` / `renameat` / parent-fd write operations. Configure `FS_SAFE_PYTHON_MODE=require` when helper startup must fail closed, or `off` when you need a no-Python runtime. See [Python helper policy](python-helper.md).
90
- - **Windows:** Falls back to the safest Node-level behavior available. `O_NOFOLLOW` is not honored. Some fd-relative POSIX hardening is unavailable. The library does the path canonicalization, identity, and atomic-rename checks it can.
111
+ The macOS `F_GETPATH` verification is an escape detector, not a race-atomic guarantee. A hostile same-UID process can rename a directory after `O_RESOLVE_BENEATH` or the manual walk and race the post-open sample or a later descriptor-relative mutation. The native result therefore remains `best-effort` on macOS even when the kernel flag is available. No policy decision is attached to these labels; callers can inspect the fact and decide what their own threat model requires.
91
112
 
92
- The library does not advertise different security guarantees per platform it advertises the same surface and relies on the strongest mechanism the platform offers.
113
+ The public `OpenResult`, `ReadResult`, and `WritableOpenResult` expose `containment`. Those root APIs currently report `best-effort`; direct native `openBeneath()` reports the platform value above. No-replace publication uses `renameat2(RENAME_NOREPLACE)` on Linux, `renameatx_np(RENAME_EXCL)` on macOS, and `FileRenameInfoEx` with replacement disabled on Windows, but those separate mutation semantics do not upgrade an open result's containment label.
93
114
 
94
115
  ## Limitations to keep in mind
95
116
 
@@ -98,11 +119,11 @@ The library does not advertise different security guarantees per platform — it
98
119
  | Not ambient authority removal | Code that can import `node:fs` can still bypass the handle. Keep caller-controlled path operations behind `root()` by convention, review, and tests. |
99
120
  | Absolute paths are escape hatches | APIs that accept or return absolute paths exist for audit, ingest, and advanced composition. Prefer root-relative names in normal application flow. |
100
121
  | Not a mount boundary | `root()` keeps path traversal inside the directory tree and blocks known unsafe read device paths, but it does not make bind mounts or virtual filesystems safe to expose wholesale. |
101
- | Per-call, not per-session | Another process with the same privileges can still mutate the tree between two separate calls. Use one verb method for the operation you need to make race-resistant. |
122
+ | Per-call, not per-session | Another process with the same privileges can still mutate the tree between calls, and best-effort mechanisms retain documented same-call race windows. Use one verb method to minimize the window and inspect its reported containment class. |
102
123
  | Hardlink rejection is best-effort | Link-count checks depend on platform metadata. Treat `hardlinks: "reject"` as a tripwire, not an authorization primitive. |
103
124
  | Mode bits are not a full policy engine | `replaceFileAtomic` and secret-file helpers set requested modes, but you should still set umask and inspect modes when policy requires it. |
104
125
  | Archive extraction is path safety, not content safety | Unsafe entry paths and links are rejected; malicious payload contents remain your application layer's problem. |
105
- | Helper failures degrade fd-relative hardening | `helper-unavailable` falls back in `auto` mode and fails closed in `require` mode. Atomicity and identity checks remain, but parent-directory swaps between validation and mutation are less tightly pinned without the helper. |
126
+ | Native package unavailable | `helper-unavailable` falls back in `auto` mode and fails closed in `require` mode for native-backed operations. Guarded JavaScript atomicity and identity checks remain. |
106
127
  | FUSE mounts with rename-unstable inode numbers | Some FUSE mounts (rclone is a confirmed example) do not preserve source inode identity at the rename destination. The explicit `renameIdentity: "verify-content-with-lock"` compatibility mode verifies content under a cooperative lock for that boundary only; subsequent path identity checks and the default remain strict. See [Writing](writing.md) for the weaker opt-in contract. |
107
128
 
108
129
  ## Recommended deployment shape