@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
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
 
@@ -61,6 +74,19 @@ When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink >
61
74
 
62
75
  Within one process, async writes to the same target are queued so their temp-write/rename phases do not overlap. Cross-process writers still need an external protocol such as the sidecar lock helpers.
63
76
 
77
+ ### Directory durability
78
+
79
+ `pinDirectory()` opens a directory without following its final component on
80
+ POSIX, verifies the descriptor against the pathname identity and canonical
81
+ path, and repeats those checks around synchronization. `ensureDurableDirectory()`
82
+ pins the nearest existing ancestor and each newly created segment before
83
+ synchronizing every new directory edge from the leaf upward.
84
+
85
+ Known Windows directory-flush limitations are returned as an explicit
86
+ `unsupported` outcome. POSIX and other I/O failures propagate from the strict
87
+ API. The separately named best-effort helpers intentionally provide no crash
88
+ durability guarantee.
89
+
64
90
  ### Archive extraction
65
91
 
66
92
  `extractArchive` first stages into a private temp directory (mode 0700) outside the destination, validates each entry path against `..` and absolute prefixes, refuses link-type entries by default, enforces entry count and byte budgets, and only then merges the staged tree into the destination through the same boundary checks used by direct writes.
@@ -71,12 +97,20 @@ A library cannot revoke its own caller's authority. If your code chooses to bypa
71
97
 
72
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.
73
99
 
74
- ## 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. |
75
110
 
76
- - **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).
77
- - **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.
78
112
 
79
- 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.
80
114
 
81
115
  ## Limitations to keep in mind
82
116
 
@@ -85,11 +119,11 @@ The library does not advertise different security guarantees per platform — it
85
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. |
86
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. |
87
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. |
88
- | 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. |
89
123
  | Hardlink rejection is best-effort | Link-count checks depend on platform metadata. Treat `hardlinks: "reject"` as a tripwire, not an authorization primitive. |
90
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. |
91
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. |
92
- | 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. |
93
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. |
94
128
 
95
129
  ## Recommended deployment shape
@@ -42,6 +42,9 @@ function withFileLock<T, TPayload>(
42
42
  ): Promise<T>;
43
43
 
44
44
  function createFileLockManager(key: string): FileLockManager;
45
+
46
+ function acquireFileLockSync<TPayload>(targetPath: string, options: FileLockSyncAcquireOptions<TPayload>): FileLockSyncHandle;
47
+ function withFileLockSync<T, TPayload>(targetPath: string, options: FileLockSyncAcquireOptions<TPayload>, fn: () => T): T;
45
48
  ```
46
49
 
47
50
  `managerKey` is an optional identifier used to keep state isolated across multiple lock domains in the same process. Use distinct keys for distinct domains (`"snapshot"`, `"compact"`, `"build"`). If omitted, fs-safe derives one from the target path.
@@ -56,7 +59,7 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
56
59
  timeoutMs?: number; // overall acquire deadline; default unbounded
57
60
  retry?: FileLockRetryOptions;
58
61
  staleRecovery?: "fail-closed" | "remove-if-unchanged"; // default "fail-closed"
59
- allowReentrant?: boolean; // if this process already holds it, increment a count instead of failing
62
+ reentrantOwner?: string; // logical holder identity for owner-scoped nesting
60
63
  payload: () => TPayload | Promise<TPayload>;
61
64
  shouldReclaim?: (params: {
62
65
  lockPath: string;
@@ -73,11 +76,15 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
73
76
  payload: Record<string, unknown> | null;
74
77
  }) => boolean | Promise<boolean>;
75
78
  metadata?: Record<string, unknown>; // attached to heldEntries() output for diagnostics
79
+ parsePayload?: (raw: string) => unknown;
80
+ lockRoot?: Root;
81
+ onCompromised?: (info: { lockPath: string; normalizedTargetPath: string }) => void;
82
+ compromiseCheckIntervalMs?: number;
76
83
  };
77
84
 
78
85
  type FileLockRetryOptions = {
79
86
  retries?: number; // number of retry attempts after the first failure
80
- factor?: number; // exponential backoff factor (default 2)
87
+ factor?: number; // exponential backoff factor (default 1: constant delay)
81
88
  minTimeout?: number; // initial delay (ms)
82
89
  maxTimeout?: number; // delay cap (ms)
83
90
  randomize?: boolean; // jitter
@@ -85,6 +92,59 @@ type FileLockRetryOptions = {
85
92
  ```
86
93
 
87
94
  `payload` is a function so you can re-evaluate it on each retry (e.g. timestamp, PID).
95
+ `parsePayload` replaces JSON parsing for legacy or custom sidecars. Its `unknown`
96
+ result is passed to `shouldReclaim` and `shouldRemoveStaleLock`, allowing PID,
97
+ process-start, argv, or role schemas to remain application-owned.
98
+
99
+ ## Owner-scoped reentrancy
100
+
101
+ Version 0.5 removes the unsound process-scoped `allowReentrant` boolean and
102
+ replaces it with `reentrantOwner`. When a manager already holds the canonical
103
+ target path, another acquisition reuses that sidecar only when both acquisitions
104
+ provide the same owner string. Each acquisition gets an idempotent release
105
+ handle; the sidecar remains until the last reference is released. A different or
106
+ missing owner waits under the normal contention, retry, and timeout policy. A
107
+ known live in-process holder is never stale-reclaimed by its own manager.
108
+
109
+ This supports logical session writers that may reach one file through real and
110
+ symlinked parent paths:
111
+
112
+ ```ts
113
+ const managerKey = "session-write-locks";
114
+ const reentrantOwner = `session:${sessionId}:operation:${operationId}`;
115
+
116
+ const outer = await acquireFileLock(realSessionPath, {
117
+ managerKey,
118
+ reentrantOwner,
119
+ staleMs: 60_000,
120
+ payload: () => ({ pid: process.pid, operationId }),
121
+ });
122
+ const nested = await acquireFileLock(symlinkedSessionPath, {
123
+ managerKey,
124
+ reentrantOwner,
125
+ staleMs: 60_000,
126
+ payload: () => ({ pid: process.pid, operationId }),
127
+ });
128
+
129
+ await nested.release(); // sidecar remains for outer
130
+ await outer.release(); // final reference removes it
131
+ ```
132
+
133
+ The manager domain and canonical target path are part of the identity, so
134
+ aliased paths must use the same `managerKey`. The owner key must identify one
135
+ logical holder or call chain. **Never use a process-wide or other shared constant
136
+ for unrelated tasks**: doing so would admit concurrent work to the same critical
137
+ section and recreate the lost-update bug that removed `allowReentrant`.
138
+
139
+ Omit `reentrantOwner` for ordinary acquisitions. `jsonStore` does so and keeps
140
+ its separate canonical-path mutation queue. The synchronous APIs implement the
141
+ same owner/refcount rules; a mismatched synchronous acquisition blocks the
142
+ calling thread according to its retry and timeout options.
143
+
144
+ Pass `lockRoot` to place sidecar create, read, verification, and removal behind
145
+ an existing `Root` capability. `lockPath` must resolve inside that root.
146
+ Identity-conditioned removal remains the only release and reclaim deletion
147
+ path.
88
148
 
89
149
  ## Release handle
90
150
 
@@ -92,25 +152,49 @@ type FileLockRetryOptions = {
92
152
  type FileLockHandle = {
93
153
  lockPath: string;
94
154
  normalizedTargetPath: string;
155
+ verifyStillHeld: () => Promise<boolean>;
95
156
  release: () => Promise<void>;
96
157
  [Symbol.asyncDispose](): Promise<void>;
97
158
  };
98
159
  ```
99
160
 
161
+ `verifyStillHeld()` compares the current sidecar with the ownership snapshot
162
+ captured at acquisition. Set `compromiseCheckIntervalMs` together with
163
+ `onCompromised` for a cheap periodic check; the callback fires once after the
164
+ sidecar no longer matches. This is detection, not revocation of work already in
165
+ progress.
166
+
167
+ ## Synchronous locks
168
+
169
+ `acquireFileLockSync()` and `withFileLockSync()` mirror filesystem arbitration,
170
+ retry, payload parsing, stale policy, guarded identity-conditioned reclaim,
171
+ verification, and compromise monitoring. They do not use the async manager
172
+ queue, support async callbacks, or provide same-process reentrancy. Retry waits
173
+ block the calling thread; use the async API in request-serving code.
174
+
100
175
  Always release in a `finally`:
101
176
 
102
177
  ```ts
103
- const handle = await acquireFileLock(targetPath, {
178
+ import { acquireFileLockSync } from "@openclaw/fs-safe/file-lock";
179
+
180
+ const handle = acquireFileLockSync("/var/lib/app/schema.json", {
104
181
  staleMs: 60_000,
105
- payload: () => ({ pid: process.pid }),
182
+ timeoutMs: 5_000,
183
+ retry: { retries: 20, minTimeout: 25, maxTimeout: 250 },
184
+ payload: () => ({ pid: process.pid, operation: "schema-migration" }),
106
185
  });
107
186
  try {
108
- await doExclusiveWork();
187
+ if (!handle.verifyStillHeld()) throw new Error("migration lock was replaced");
188
+ migrateSchemaSynchronously();
109
189
  } finally {
110
- await handle.release();
190
+ handle.release();
111
191
  }
112
192
  ```
113
193
 
194
+ The sync payload, reclaim, and parsing callbacks must also be synchronous. This
195
+ shape is appropriate for a short boot migration; it is a poor fit for a server
196
+ request because retry backoff uses a blocking wait.
197
+
114
198
  If your process dies before `release()` runs and skips the exit handler, the sidecar remains. Once `staleMs` elapses (or your `shouldReclaim` returns true), acquisition fails closed by default instead of deleting by path.
115
199
 
116
200
  ## `withFileLock` — common shape made one-liner
@@ -174,7 +258,7 @@ const handle = await acquireFileLock(targetPath, {
174
258
  });
175
259
  ```
176
260
 
177
- `heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case). A `true` result marks the observed sidecar as stale; `staleRecovery` then decides whether acquisition fails closed or attempts caller-approved removal.
261
+ `heldByThisProcess` is true when this manager already holds the lock. A `true` result marks the observed sidecar as stale; `staleRecovery` then decides whether acquisition fails closed or attempts caller-approved removal.
178
262
 
179
263
  ## Stale recovery: guarded `remove-if-unchanged`
180
264
 
@@ -248,3 +332,4 @@ await withFileLock(
248
332
 
249
333
  - [Atomic writes](atomic.md) — single-writer atomicity that often replaces the need for a lock entirely.
250
334
  - `createAsyncLock` from `@openclaw/fs-safe/advanced` — in-process serialization for a single Node process.
335
+ - [Migrating to 0.5](migrating-to-0.5.md) — choosing sync versus async lock APIs.
package/docs/temp.md CHANGED
@@ -23,6 +23,7 @@ The compact factory. Returns:
23
23
  ```ts
24
24
  type TempWorkspace = {
25
25
  dir: string;
26
+ identity: { dev: number | bigint; ino: number | bigint };
26
27
  store: FileStore;
27
28
  path(fileName: string): string;
28
29
  write(fileName: string, data: string | Uint8Array): Promise<string>;
@@ -30,7 +31,7 @@ type TempWorkspace = {
30
31
  writeJson(fileName: string, data: unknown, options?: { trailingNewline?: boolean }): Promise<string>;
31
32
  copyIn(fileName: string, sourcePath: string): Promise<string>;
32
33
  read(fileName: string): Promise<Buffer>;
33
- cleanup(): Promise<void>;
34
+ cleanup(): Promise<"removed" | "missing" | "identity-mismatch">;
34
35
  [Symbol.asyncDispose](): Promise<void>;
35
36
  };
36
37
  ```
@@ -58,6 +59,28 @@ await state.write({ ready: true });
58
59
  The workspace owns cleanup; the store is only a view over the workspace
59
60
  directory.
60
61
 
62
+ The identity receipt is captured when the workspace is created. Manual,
63
+ disposal, and process-exit cleanup remove the path only while `lstat` still
64
+ matches that receipt. If another actor renames the workspace away and places a
65
+ new directory at the old name, cleanup returns `"identity-mismatch"` and leaves
66
+ the replacement untouched. Disposal hooks perform the same check and ignore
67
+ the returned status.
68
+
69
+ When cleanup is part of a retention or audit decision, inspect the receipt
70
+ instead of treating cleanup as fire-and-forget:
71
+
72
+ ```ts
73
+ const workspace = await tempWorkspace({ rootDir: "/var/lib/app/tmp", prefix: "restore-" });
74
+ try {
75
+ await restoreInto(workspace.dir);
76
+ } finally {
77
+ const cleanup = await workspace.cleanup();
78
+ if (cleanup === "identity-mismatch") {
79
+ alertOperator("restore workspace path was replaced; replacement preserved");
80
+ }
81
+ }
82
+ ```
83
+
61
84
  The sync variant `tempWorkspaceSync` exposes the same surface with sync return
62
85
  types and a `FileStoreSync` at `workspace.store`.
63
86
 
@@ -31,6 +31,15 @@ type FsSafeTestHooks = {
31
31
  afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
32
32
  beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
33
33
  afterOpen?: (filePath: string, handle: FileHandle) => Promise<void> | void;
34
+ beforeArchiveOutputMutation?: (operation: "mkdir" | "chmod", targetPath: string) => Promise<void> | void;
35
+ beforeFileStorePruneDescend?: (dirPath: string) => Promise<void> | void;
36
+ beforeFileStoreSyncPrivateWrite?: (filePath: string) => void;
37
+ beforeRootFallbackMutation?: (operation: "mkdir" | "move" | "remove", targetPath: string) => Promise<void> | void;
38
+ afterPinnedWriteFallbackRename?: (targetPath: string) => Promise<void> | void;
39
+ beforeSiblingTempWrite?: (tempPath: string) => Promise<void> | void;
40
+ beforeTrashMove?: (targetPath: string, destPath: string) => void;
41
+ afterPublishTargetCreated?: (method, targetPath, identity) => Promise<void> | void;
42
+ beforePublishDirectorySync?: (method, targetPath, identity) => Promise<void> | void;
34
43
  };
35
44
  ```
36
45
 
@@ -39,8 +48,19 @@ type FsSafeTestHooks = {
39
48
  | `afterPreOpenLstat` | A pre-open `lstat` has just resolved. Use this to swap a path between validation and open. |
40
49
  | `beforeOpen` | The library is about to call `open(path, flags)`. Use this to inject a TOCTOU window. |
41
50
  | `afterOpen` | An open just succeeded. Use this to mutate state before the post-open identity check runs. |
42
-
43
- Each hook may be sync or async; async hooks are awaited.
51
+ | `beforeArchiveOutputMutation` | Archive staging is about to create a directory or apply a mode. |
52
+ | `beforeFileStorePruneDescend` | File-store pruning is about to descend into a directory. |
53
+ | `beforeFileStoreSyncPrivateWrite` | A synchronous private-store write is about to mutate its target. |
54
+ | `beforeRootFallbackMutation` | A guarded JS root fallback is about to mkdir, move, or remove. |
55
+ | `afterPinnedWriteFallbackRename` | A fallback rename committed and post-commit identity checks have not run yet. |
56
+ | `beforeSiblingTempWrite` | A sibling temp file exists and its writer is about to run. |
57
+ | `beforeTrashMove` | Trash handling is about to move the target. |
58
+ | `afterPublishTargetCreated` | Exclusive publication created its target and final fences have not run yet. |
59
+ | `beforePublishDirectorySync` | Publication verified the target and is about to sync its parent directory. |
60
+
61
+ Hooks typed `Promise<void> | void` may be sync or async and are awaited.
62
+ Hooks used by synchronous code paths are typed `void` and must not return a
63
+ promise.
44
64
 
45
65
  ## Usage
46
66
 
package/docs/testing.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # Testing
2
2
 
3
- `@openclaw/fs-safe/test-hooks` exposes a small set of test-only injection points. They are inert in production: the hooks only activate when `process.env.NODE_ENV === "test"`. Outside test mode, calls to set hooks are no-ops, so leaking a test setup line into production is safe but ineffective.
3
+ `@openclaw/fs-safe/test-hooks` exposes test-only injection points. Registration
4
+ is allowed only when `process.env.NODE_ENV === "test"` or
5
+ `process.env.VITEST === "true"`; registering a non-empty hook set elsewhere
6
+ throws. Production code must not import this subpath.
4
7
 
5
8
  ```ts
6
9
  import {
@@ -14,7 +17,7 @@ The double-underscore prefix is a deliberate "hands off" signal: production code
14
17
  ## When to reach for hooks
15
18
 
16
19
  - Reproduce a TOCTOU race deterministically: simulate a symlink swap between resolve and open, or between write and rename.
17
- - Force Node-only behavior without uninstalling Python from your runners.
20
+ - Force guarded JavaScript behavior without removing bundled binaries from your runners.
18
21
  - Inject latency to test cancellation/timeout paths.
19
22
 
20
23
  If you don't need to inject a race, you don't need hooks — most tests should drive the library through normal calls and assert on observable behavior.
@@ -26,6 +29,10 @@ type FsSafeTestHooks = {
26
29
  afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
27
30
  beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
28
31
  afterOpen?: (filePath: string, handle: import("node:fs/promises").FileHandle) => Promise<void> | void;
32
+ afterPublishTargetCreated?: (method, targetPath, identity) => Promise<void> | void;
33
+ beforePublishDirectorySync?: (method, targetPath, identity) => Promise<void> | void;
34
+ // Additional archive, store, root-fallback, temp, and trash race hooks are
35
+ // documented on the focused Test hooks reference page.
29
36
  };
30
37
 
31
38
  function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void;
@@ -37,6 +44,8 @@ Hooks are called at well-defined points in the library's hot paths:
37
44
  - **`afterPreOpenLstat`** — runs after the pre-open `lstat`. A common use is to swap the path's target via `fs.symlink`/`fs.unlink` to drive a TOCTOU race.
38
45
  - **`beforeOpen`** — runs before `fs.open` with the exact flags the root read path will use.
39
46
  - **`afterOpen`** — runs after the file handle is opened. Useful to wrap handle methods or inject a size race before a stream is consumed.
47
+ - **`afterPublishTargetCreated`** — runs after exclusive publication created a target but before its final fences.
48
+ - **`beforePublishDirectorySync`** — runs after target verification and immediately before strict parent sync; useful for exercising `onSyncFailure`.
40
49
 
41
50
  `__setFsSafeTestHooksForTest(undefined)` clears all hooks. Always clean up between tests.
42
51
 
@@ -81,20 +90,20 @@ it("rejects a swap between resolve and open", async () => {
81
90
 
82
91
  The `code` may be `symlink` (caught at open by `O_NOFOLLOW`) or `path-mismatch` (caught by the post-open identity check) depending on platform — both are correct refusals.
83
92
 
84
- ## Example: force Node-only fallback behavior
93
+ ## Example: force guarded JavaScript fallback behavior
85
94
 
86
95
  ```ts
87
- import { configureFsSafePython } from "@openclaw/fs-safe/config";
96
+ import { configureFsSafeNative } from "@openclaw/fs-safe/config";
88
97
 
89
98
  beforeEach(() => {
90
- configureFsSafePython({ mode: "off" });
99
+ configureFsSafeNative({ mode: "off" });
91
100
  });
92
101
 
93
102
  afterEach(() => {
94
- configureFsSafePython({ mode: "auto", pythonPath: undefined });
103
+ configureFsSafeNative({ mode: "auto" });
95
104
  });
96
105
 
97
- it("runs without the Python helper", async () => {
106
+ it("runs without the native helper", async () => {
98
107
  const fs = await root(dir);
99
108
  await fs.write("file.txt", "ok");
100
109
  await expect(fs.readText("file.txt")).resolves.toBe("ok");
@@ -116,6 +125,8 @@ afterEach(() => {
116
125
 
117
126
  A global hook clear in your test setup file is a good safety net.
118
127
 
128
+ See the [complete Test hooks reference](test-hooks.md) for every optional hook.
129
+
119
130
  ## Patterns for testing fs-safe-using code
120
131
 
121
132
  You usually don't need hooks. Most tests follow this shape:
package/docs/types.md CHANGED
@@ -65,18 +65,20 @@ Returned by `Root.open()` and `Root.read()`:
65
65
  ```ts
66
66
  type OpenResult = {
67
67
  handle: import("node:fs/promises").FileHandle;
68
+ containment: "kernel-atomic" | "best-effort";
68
69
  realPath: string;
69
70
  stat: import("node:fs").Stats;
70
71
  };
71
72
 
72
73
  type ReadResult = {
73
74
  buffer: Buffer;
75
+ containment: "kernel-atomic" | "best-effort";
74
76
  realPath: string;
75
77
  stat: import("node:fs").Stats;
76
78
  };
77
79
  ```
78
80
 
79
- `realPath` is the canonical real path the read or open landed on, after symlink resolution; `stat` is the verified `fstat` result.
81
+ `realPath` is the canonical real path the read or open landed on, after symlink resolution; `stat` is the verified `fstat` result. Public root results currently report `containment: "best-effort"`; the union also describes direct native `openBeneath()` results, which report `"kernel-atomic"` on Linux. See the [security model](security-model.md#containment-guarantees-by-platform).
80
82
 
81
83
  ## `RootDefaults` / `RootOptions`
82
84