@openclaw/fs-safe 0.4.6 → 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 (246) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +110 -41
  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/directory-durability.d.ts +44 -0
  56. package/dist/directory-durability.d.ts.map +1 -0
  57. package/dist/directory-durability.js +299 -0
  58. package/dist/durability.d.ts +4 -0
  59. package/dist/durability.d.ts.map +1 -0
  60. package/dist/durability.js +3 -0
  61. package/dist/errors.d.ts +4 -1
  62. package/dist/errors.d.ts.map +1 -1
  63. package/dist/errors.js +2 -0
  64. package/dist/file-hash.d.ts +10 -0
  65. package/dist/file-hash.d.ts.map +1 -0
  66. package/dist/file-hash.js +73 -0
  67. package/dist/file-lock-sync.d.ts +35 -0
  68. package/dist/file-lock-sync.d.ts.map +1 -0
  69. package/dist/file-lock-sync.js +233 -0
  70. package/dist/file-lock.d.ts +2 -0
  71. package/dist/file-lock.d.ts.map +1 -1
  72. package/dist/file-lock.js +1 -0
  73. package/dist/filename.d.ts.map +1 -1
  74. package/dist/filename.js +4 -1
  75. package/dist/fsync.d.ts +1 -1
  76. package/dist/fsync.d.ts.map +1 -1
  77. package/dist/fsync.js +1 -21
  78. package/dist/guarded-mkdir.d.ts +7 -1
  79. package/dist/guarded-mkdir.d.ts.map +1 -1
  80. package/dist/guarded-mkdir.js +45 -3
  81. package/dist/index.d.ts +4 -3
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +1 -1
  84. package/dist/json-document-store.d.ts.map +1 -1
  85. package/dist/json-document-store.js +30 -15
  86. package/dist/move-path.d.ts.map +1 -1
  87. package/dist/move-path.js +101 -17
  88. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  89. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  90. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  91. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  92. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  93. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  94. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
  95. package/dist/native-binding.d.ts +79 -0
  96. package/dist/native-binding.d.ts.map +1 -0
  97. package/dist/native-binding.js +1 -0
  98. package/dist/native-config.d.ts +17 -0
  99. package/dist/native-config.d.ts.map +1 -0
  100. package/dist/native-config.js +71 -0
  101. package/dist/native-operations.d.ts +21 -0
  102. package/dist/native-operations.d.ts.map +1 -0
  103. package/dist/native-operations.js +115 -0
  104. package/dist/native-pinned-write.d.ts +5 -0
  105. package/dist/native-pinned-write.d.ts.map +1 -0
  106. package/dist/native-pinned-write.js +115 -0
  107. package/dist/native.d.ts +14 -0
  108. package/dist/native.d.ts.map +1 -0
  109. package/dist/native.js +204 -0
  110. package/dist/output-sibling.d.ts +8 -0
  111. package/dist/output-sibling.d.ts.map +1 -0
  112. package/dist/output-sibling.js +122 -0
  113. package/dist/output.d.ts +2 -0
  114. package/dist/output.d.ts.map +1 -1
  115. package/dist/output.js +32 -5
  116. package/dist/owner-dacl.d.ts +31 -0
  117. package/dist/owner-dacl.d.ts.map +1 -0
  118. package/dist/owner-dacl.js +27 -0
  119. package/dist/path-policy.js +1 -1
  120. package/dist/permissions-public.d.ts +2 -0
  121. package/dist/permissions-public.d.ts.map +1 -1
  122. package/dist/permissions-public.js +2 -0
  123. package/dist/permissions.d.ts.map +1 -1
  124. package/dist/permissions.js +6 -0
  125. package/dist/pinned-operation.d.ts +2 -0
  126. package/dist/pinned-operation.d.ts.map +1 -0
  127. package/dist/pinned-operation.js +35 -0
  128. package/dist/pinned-write.d.ts +3 -16
  129. package/dist/pinned-write.d.ts.map +1 -1
  130. package/dist/pinned-write.js +20 -85
  131. package/dist/private-directory.d.ts +5 -0
  132. package/dist/private-directory.d.ts.map +1 -0
  133. package/dist/private-directory.js +13 -0
  134. package/dist/private-temp-workspace.d.ts +6 -2
  135. package/dist/private-temp-workspace.d.ts.map +1 -1
  136. package/dist/private-temp-workspace.js +40 -13
  137. package/dist/publish-file-failure.d.ts +27 -0
  138. package/dist/publish-file-failure.d.ts.map +1 -0
  139. package/dist/publish-file-failure.js +21 -0
  140. package/dist/publish-file.d.ts +21 -0
  141. package/dist/publish-file.d.ts.map +1 -0
  142. package/dist/publish-file.js +351 -0
  143. package/dist/read-opened-file.d.ts +3 -0
  144. package/dist/read-opened-file.d.ts.map +1 -1
  145. package/dist/read-opened-file.js +1 -0
  146. package/dist/replace-file-copy-fallback.d.ts +34 -0
  147. package/dist/replace-file-copy-fallback.d.ts.map +1 -0
  148. package/dist/replace-file-copy-fallback.js +339 -0
  149. package/dist/replace-file.d.ts +6 -2
  150. package/dist/replace-file.d.ts.map +1 -1
  151. package/dist/replace-file.js +39 -90
  152. package/dist/root-context.d.ts +4 -1
  153. package/dist/root-context.d.ts.map +1 -1
  154. package/dist/root-context.js +20 -1
  155. package/dist/root-impl.d.ts +5 -0
  156. package/dist/root-impl.d.ts.map +1 -1
  157. package/dist/root-impl.js +128 -225
  158. package/dist/root-path-existing.d.ts +3 -0
  159. package/dist/root-path-existing.d.ts.map +1 -0
  160. package/dist/root-path-existing.js +69 -0
  161. package/dist/root-path.d.ts +1 -2
  162. package/dist/root-path.d.ts.map +1 -1
  163. package/dist/root-path.js +67 -81
  164. package/dist/root-walk.d.ts +41 -0
  165. package/dist/root-walk.d.ts.map +1 -0
  166. package/dist/root-walk.js +114 -0
  167. package/dist/root.d.ts +2 -0
  168. package/dist/root.d.ts.map +1 -1
  169. package/dist/secret-file.d.ts +5 -2
  170. package/dist/secret-file.d.ts.map +1 -1
  171. package/dist/secret-file.js +20 -2
  172. package/dist/secret-read-async.d.ts +4 -0
  173. package/dist/secret-read-async.d.ts.map +1 -0
  174. package/dist/secret-read-async.js +95 -0
  175. package/dist/secret.d.ts +2 -1
  176. package/dist/secret.d.ts.map +1 -1
  177. package/dist/secret.js +2 -1
  178. package/dist/sibling-temp.d.ts.map +1 -1
  179. package/dist/sibling-temp.js +2 -13
  180. package/dist/sidecar-lock-handle.d.ts +20 -0
  181. package/dist/sidecar-lock-handle.d.ts.map +1 -0
  182. package/dist/sidecar-lock-handle.js +28 -0
  183. package/dist/sidecar-lock-policy.d.ts +10 -0
  184. package/dist/sidecar-lock-policy.d.ts.map +1 -0
  185. package/dist/sidecar-lock-policy.js +29 -0
  186. package/dist/sidecar-lock-reclaim.d.ts +22 -6
  187. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  188. package/dist/sidecar-lock-reclaim.js +106 -20
  189. package/dist/sidecar-lock-types.d.ts +56 -0
  190. package/dist/sidecar-lock-types.d.ts.map +1 -0
  191. package/dist/sidecar-lock-types.js +1 -0
  192. package/dist/sidecar-lock.d.ts +2 -45
  193. package/dist/sidecar-lock.d.ts.map +1 -1
  194. package/dist/sidecar-lock.js +94 -55
  195. package/dist/temp-cleanup.d.ts +7 -1
  196. package/dist/temp-cleanup.d.ts.map +1 -1
  197. package/dist/temp-cleanup.js +35 -5
  198. package/dist/temp.d.ts +2 -1
  199. package/dist/temp.d.ts.map +1 -1
  200. package/dist/test-hooks.d.ts +3 -0
  201. package/dist/test-hooks.d.ts.map +1 -1
  202. package/dist/windows-permissions-native.d.ts +9 -0
  203. package/dist/windows-permissions-native.d.ts.map +1 -0
  204. package/dist/windows-permissions-native.js +31 -0
  205. package/docs/advanced.md +1 -1
  206. package/docs/archive.md +131 -13
  207. package/docs/atomic.md +30 -1
  208. package/docs/config.md +51 -21
  209. package/docs/durability.md +299 -0
  210. package/docs/errors.md +36 -6
  211. package/docs/filename.md +0 -0
  212. package/docs/index.md +11 -7
  213. package/docs/install.md +27 -26
  214. package/docs/json-store.md +18 -3
  215. package/docs/migrating-to-0.5.md +196 -0
  216. package/docs/native-helper.md +85 -0
  217. package/docs/native.md +134 -0
  218. package/docs/output.md +37 -11
  219. package/docs/path.md +1 -1
  220. package/docs/permissions.md +84 -1
  221. package/docs/quickstart.md +5 -2
  222. package/docs/reading.md +4 -4
  223. package/docs/root.md +31 -15
  224. package/docs/secret-file.md +50 -1
  225. package/docs/security-model.md +42 -8
  226. package/docs/sidecar-lock.md +92 -7
  227. package/docs/temp.md +24 -1
  228. package/docs/test-hooks.md +22 -2
  229. package/docs/testing.md +18 -7
  230. package/docs/types.md +3 -1
  231. package/docs/walk.md +55 -0
  232. package/docs/writing.md +3 -3
  233. package/package.json +12 -2
  234. package/dist/pinned-helper.d.ts +0 -9
  235. package/dist/pinned-helper.d.ts.map +0 -1
  236. package/dist/pinned-helper.js +0 -19
  237. package/dist/pinned-path.d.ts +0 -7
  238. package/dist/pinned-path.d.ts.map +0 -1
  239. package/dist/pinned-path.js +0 -21
  240. package/dist/pinned-python-config.d.ts +0 -9
  241. package/dist/pinned-python-config.d.ts.map +0 -1
  242. package/dist/pinned-python-config.js +0 -38
  243. package/dist/pinned-python.d.ts +0 -12
  244. package/dist/pinned-python.d.ts.map +0 -1
  245. package/dist/pinned-python.js +0 -688
  246. package/docs/python-helper.md +0 -103
@@ -0,0 +1,196 @@
1
+ ---
2
+ title: Migrating to 0.5
3
+ description: "Ordered checklist for moving a 0.4 consumer from the Python helper to native mode and adopting the 0.5 API contracts."
4
+ ---
5
+
6
+ # Migrating from 0.4 to 0.5
7
+
8
+ Use this checklist from top to bottom. Version 0.5 replaces the Python worker,
9
+ changes the default archive mode policy, and adds explicit contracts for
10
+ publication, walking, locks, secrets, and native-only features. Nothing in this
11
+ guide requires a Rust toolchain: all supported native binaries are prebuilt and
12
+ bundled in `@openclaw/fs-safe`.
13
+
14
+ ## 1. Update the package and runtime
15
+
16
+ - Run on Node.js 22 or newer.
17
+ - Update `@openclaw/fs-safe` and regenerate every lock or shrinkwrap file your
18
+ deployment consumes.
19
+ - Keep optional dependencies enabled if you want JavaScript ZIP/TAR support.
20
+ Native loading no longer depends on optional packages because all seven
21
+ binaries ship in `@openclaw/fs-safe`. An install that omits optional packages
22
+ can still import fs-safe, but missing JS archive decoders fail with actionable
23
+ errors.
24
+
25
+ If you call `resolveRootPath()` or `resolveRootPathSync()` directly, upgrade to
26
+ 0.5: versions through 0.4.7 could approve an in-root symlink traversal that
27
+ resolved outside the root. `root()` handles were not affected. See the
28
+ [affected versions and exposure](security-model.md#affected-versions-exposure).
29
+
30
+ ## 2. Replace Python helper configuration
31
+
32
+ Change startup configuration before the first filesystem operation:
33
+
34
+ ```ts
35
+ import { configureFsSafeNative } from "@openclaw/fs-safe/config";
36
+
37
+ configureFsSafeNative({ mode: "auto" });
38
+ ```
39
+
40
+ | Remove from 0.4 | Use in 0.5 |
41
+ |---|---|
42
+ | `configureFsSafePython({ mode })` | `configureFsSafeNative({ mode })` |
43
+ | `FS_SAFE_PYTHON_MODE` | `FS_SAFE_NATIVE_MODE` |
44
+ | `OPENCLAW_FS_SAFE_PYTHON_MODE` | `OPENCLAW_FS_SAFE_NATIVE_MODE` |
45
+ | `pythonPath`, `FS_SAFE_PYTHON`, pinned-Python aliases | Nothing; bundled native binaries do not use an interpreter |
46
+
47
+ The old names warn once and map `auto`, `off`, or `require` so a shipped 0.4
48
+ deployment does not silently change policy. Interpreter paths are ignored and
49
+ Python is never spawned. Treat that warning as an upgrade diagnostic, not as a
50
+ second supported helper path.
51
+
52
+ Choose the production mode deliberately:
53
+
54
+ - `auto` keeps guarded JavaScript fallbacks when a binding is unavailable.
55
+ - `off` makes fallback testing deterministic.
56
+ - `require` fails with `helper-unavailable` instead of weakening an operation
57
+ that expected native support.
58
+
59
+ See [Native helper policy](native-helper.md) and
60
+ [Native architecture](native.md).
61
+
62
+ ## 3. Audit every archive call
63
+
64
+ The 0.5 default is `entryModes: "clamp"`. Directories become `0o755`; files
65
+ become `0o644` or `0o755` when owner-execute was archived. Set
66
+ `entryModes: "preserve"` explicitly only if your 0.4 consumer intentionally
67
+ relied on archived rwx bits. Setuid, setgid, sticky bits, and archived ownership
68
+ are never restored.
69
+
70
+ ```ts
71
+ await extractArchive({
72
+ archivePath: uploadPath,
73
+ destDir: restoreRoot,
74
+ timeoutMs: 30_000,
75
+ entryModes: "clamp",
76
+ entryFilter: (entry) =>
77
+ entry.path.startsWith("snapshot/cache/") ? "skip" : "extract",
78
+ onFiltered: "skip-entry",
79
+ limits: {
80
+ maxArchiveBytes: 256 * 1024 * 1024,
81
+ maxEntries: 50_000,
82
+ maxExtractedBytes: 512 * 1024 * 1024,
83
+ maxEntryBytes: 256 * 1024 * 1024,
84
+ maxMetaEntryBytes: 1024 * 1024,
85
+ maxEntryPathComponents: 64,
86
+ },
87
+ });
88
+ ```
89
+
90
+ Returning `"skip"` rejects the archive unless `onFiltered: "skip-entry"` is
91
+ explicit. Zstd and bzip2 TAR are native-only; ZIP, TAR, and gzip retain guarded
92
+ JavaScript implementations. Catch `ArchiveLimitError` by its code, including
93
+ `archive-entry-path-components-exceeds-limit` for deep implicit-directory
94
+ attacks. See [Archive extraction](archive.md).
95
+
96
+ ## 4. Pick a publication failure policy
97
+
98
+ `publishFileExclusive()` never replaces an existing target. Choose a strategy
99
+ and decide what a post-create directory-sync failure means to your application:
100
+
101
+ ```ts
102
+ await publishFileExclusive({
103
+ sourcePath: stagedArchive,
104
+ targetPath: finalArchive,
105
+ strategy: "link-or-copy",
106
+ onSyncFailure: "preserve",
107
+ });
108
+ ```
109
+
110
+ `rollback` is the default: an unchanged target created by this call is removed
111
+ when directory sync throws. `preserve` keeps a complete but possibly
112
+ non-durable target and reports `cleanup: "preserved"` plus
113
+ `directorySync: { status: "failed", code? }` in the typed error. Backup
114
+ archives commonly need `preserve`; transactional protocols that expose only
115
+ durably committed names usually want `rollback`. See
116
+ [Directory durability](durability.md).
117
+
118
+ ## 5. Replace recursive scans with an explicit walk policy
119
+
120
+ Use `Root.walk()` for caller-controlled relative paths. Every examined entry
121
+ consumes the budget even when filtered:
122
+
123
+ ```ts
124
+ for await (const entry of workspace.walk("memory", {
125
+ maxDepth: 12,
126
+ maxEntries: 50_000,
127
+ symlinkPolicy: "skip",
128
+ entryFilter: (entry) =>
129
+ entry.kind === "directory" && entry.relativePath.endsWith("/.git")
130
+ ? "skip-subtree"
131
+ : "include",
132
+ onDirectoryError: "skip-and-report",
133
+ })) {
134
+ if (entry.kind === "directory-error") {
135
+ reportIncompleteSubtree(entry.relativePath, entry.error);
136
+ continue;
137
+ }
138
+ indexEntry(entry);
139
+ }
140
+ ```
141
+
142
+ The default directory-error policy remains `throw`. See
143
+ [Directory walking](walk.md).
144
+
145
+ ## 6. Adopt the focused concurrency and secret APIs
146
+
147
+ - Use `acquireFileLockSync()` only in synchronous boot or migration code; retry
148
+ waits block the thread. Request-serving paths should use `withFileLock()`.
149
+ - Remove the `allowReentrant` boolean from async file-lock options. If a logical
150
+ holder intentionally nests acquisition, pass the same operation-scoped
151
+ `reentrantOwner` string to each acquisition; different or missing owners
152
+ contend normally. Never replace the boolean with a process-wide constant.
153
+ Locked and unlocked `jsonStore` mutations serialize by canonical file path
154
+ and do not opt into lock reentrancy; nested same-file mutations from an update
155
+ callback fail with `store-reentrant-update`, so return the complete value from
156
+ the outer callback instead.
157
+ - Use `createSecretFileAtomic()` for first-writer-wins credentials and catch
158
+ `secret-exists`; use `writeSecretFileAtomic()` only when replacement is the
159
+ intended protocol.
160
+ - Async `readSecretFile()` is strict; `tryReadSecretFile()` returns `undefined`
161
+ only for missing or blank content and still rejects suspicious files.
162
+ - Check `tempWorkspace.cleanup()` results when ownership matters;
163
+ `identity-mismatch` deliberately preserves a replacement path.
164
+
165
+ See [File locks](sidecar-lock.md), [Secret files](secret-file.md), and
166
+ [Temp workspaces](temp.md).
167
+
168
+ ## 7. Gate native-only features
169
+
170
+ `createPrivateDirectory()` is Windows-only and native-only because a pathname
171
+ fallback cannot promise the same creation-time DACL. Zstd/bzip2 extraction and
172
+ `strategy: "rename-noreplace"` are also native-only. Test the unavailable path
173
+ instead of assuming installation always succeeds.
174
+
175
+ ## 8. Run both behavior families in CI
176
+
177
+ For each consumer workflow that matters:
178
+
179
+ 1. Run once with `FS_SAFE_NATIVE_MODE=auto` on every supported OS.
180
+ 2. Run once with `FS_SAFE_NATIVE_MODE=off` to prove the JavaScript fallback.
181
+ 3. Run native-required or native-only cases with `FS_SAFE_NATIVE_MODE=require`.
182
+ 4. Exercise archive traversal/link/depth limits, publication sync failure, and
183
+ partial-walk reporting with production-shaped fixtures.
184
+
185
+ For downstream staging and backup consumers:
186
+
187
+ - [ ] Replace private whole-file hashing with `sha256File(path | FileHandle)`
188
+ from `durability`; native mode keeps digest work off the event loop and
189
+ the JavaScript fallback remains streaming.
190
+ - [ ] If Windows trust policy depends on exact principals, consume
191
+ `readOwnerAndDacl()` from `permissions`, reject incomplete/null/remote
192
+ descriptors as your policy requires, skip inherit-only ACEs where
193
+ appropriate, and apply the application's own SID allowlist.
194
+
195
+ The [Testing](testing.md) page documents the test hooks and mode setup used by
196
+ fs-safe itself.
@@ -0,0 +1,85 @@
1
+ ---
2
+ title: Native helper policy
3
+ description: "How fs-safe loads its bundled native filesystem primitives and how auto, require, and off affect guarded fallbacks."
4
+ ---
5
+
6
+ # Native helper policy
7
+
8
+ `@openclaw/fs-safe` itself contains seven prebuilt binaries for Linux x64/arm64 (glibc or musl), macOS x64/arm64, and Windows x64. The loader selects `dist/native/<target>/fs-safe-native.node` without optional platform packages, downloads, postinstall scripts, or a consumer Rust build. Carrying every target makes the npm tarball larger, but every installation receives the same complete artifact.
9
+
10
+ ```ts
11
+ import { configureFsSafeNative } from "@openclaw/fs-safe/config";
12
+
13
+ configureFsSafeNative({ mode: "auto" }); // default
14
+ configureFsSafeNative({ mode: "off" }); // guarded JavaScript only
15
+ configureFsSafeNative({ mode: "require" }); // fail closed when the binding is unavailable
16
+ ```
17
+
18
+ The equivalent environment variables are `FS_SAFE_NATIVE_MODE` and `OPENCLAW_FS_SAFE_NATIVE_MODE`. Accepted values are `auto`, `off`, `require`, `true`, `false`, `on`, `never`, `required`, `1`, and `0`.
19
+
20
+ ## Modes
21
+
22
+ | Mode | Behavior |
23
+ |---|---|
24
+ | `auto` | Prefer native primitives when the current bundled binary loads; otherwise silently use the guarded JavaScript path. |
25
+ | `off` | Do not load a bundled binary. Use the guarded JavaScript path deterministically. |
26
+ | `require` | Throw `FsSafeError("helper-unavailable")` instead of falling back when an operation needs the native binding and it cannot load. |
27
+
28
+ Configure the mode once during startup. Loading is lazy and cached; changing from `auto` to `require` after a failed load changes failure policy but does not repeatedly probe the binary.
29
+
30
+ ## Native boundary
31
+
32
+ The bundled native layer exposes policy-free filesystem mechanisms: beneath-root
33
+ open/mkdir/link, no-replace rename, identity reads, archive decode/execution,
34
+ clone/copy/hash workers, and Windows security descriptor calls. The TypeScript
35
+ layer owns policy, retries, filters, budgets, modes, cleanup, error
36
+ normalization, and the decision to fall back.
37
+
38
+ - Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS` and `renameat2(RENAME_NOREPLACE)`.
39
+ - macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. No-replace publication uses `renameatx_np(RENAME_EXCL)`.
40
+ - Windows uses handle-relative `NtCreateFile`, rejects reparse points, and uses `FileRenameInfoEx` with replacement disabled.
41
+
42
+ Native primitives back create-only pinned writes, async sidecar creation,
43
+ guarded publication, archive acceleration, and direct Windows ACL operations.
44
+ Equivalent JavaScript paths remain available for documented fallback-capable
45
+ features. See [Native architecture](native.md#javascript-fallback-guarantees-and-delta)
46
+ for the exact difference.
47
+
48
+ `openBeneath()` returns `{ fd, containment }`. `containment` is
49
+ `"kernel-atomic"` for Linux `openat2` and `"best-effort"` for macOS and
50
+ Windows. Public JavaScript root open/read/writable results also expose the
51
+ field and report `"best-effort"`; the label reports mechanism, not policy.
52
+
53
+ ## Migration from the Python helper
54
+
55
+ Version 0.5 removes the Python worker and interpreter-path selection. The mode
56
+ contract is unchanged, so migrate startup configuration directly:
57
+
58
+ | Python helper configuration | Native replacement |
59
+ |---|---|
60
+ | `configureFsSafePython({ mode: "auto" })` | `configureFsSafeNative({ mode: "auto" })` |
61
+ | `configureFsSafePython({ mode: "off" })` | `configureFsSafeNative({ mode: "off" })` |
62
+ | `configureFsSafePython({ mode: "require" })` | `configureFsSafeNative({ mode: "require" })` |
63
+ | `FS_SAFE_PYTHON_MODE` | `FS_SAFE_NATIVE_MODE` |
64
+ | `OPENCLAW_FS_SAFE_PYTHON_MODE` | `OPENCLAW_FS_SAFE_NATIVE_MODE` |
65
+ | `pythonPath`, `FS_SAFE_PYTHON`, and the OpenClaw interpreter-path aliases | Remove; prebuilt bindings do not use an interpreter path |
66
+
67
+ In 0.5, `configureFsSafePython` and the legacy Python environment names
68
+ remain only as an upgrade bridge. On the first config read they emit one
69
+ `DeprecationWarning` with code `FS_SAFE_PYTHON_DEPRECATED`, state the mapped
70
+ native mode, and then apply that mode. A legacy interpreter path without an
71
+ explicit mode maps to `auto` and the path itself is ignored. Native config has
72
+ the normal precedence over legacy environment config.
73
+
74
+ There is no silent alias and no Python execution fallback. The bridge exists
75
+ only to make shipped 0.4 configuration visible and predictable while the
76
+ consumer performs its 0.5 upgrade.
77
+
78
+ ## Related pages
79
+
80
+ - [Config](config.md)
81
+ - [Security model](security-model.md)
82
+ - [Writing](writing.md)
83
+ - [File locks](sidecar-lock.md)
84
+ - [Durability](durability.md)
85
+ - [Migrating to 0.5](migrating-to-0.5.md)
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