@openclaw/fs-safe 0.4.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/README.md +82 -43
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +5 -4
- package/dist/archive-errors.d.ts +11 -0
- package/dist/archive-errors.d.ts.map +1 -0
- package/dist/archive-errors.js +16 -0
- package/dist/archive-input.d.ts +12 -0
- package/dist/archive-input.d.ts.map +1 -0
- package/dist/archive-input.js +72 -0
- package/dist/archive-kind.d.ts +1 -1
- package/dist/archive-kind.d.ts.map +1 -1
- package/dist/archive-kind.js +18 -0
- package/dist/archive-limits.d.ts +10 -0
- package/dist/archive-limits.d.ts.map +1 -1
- package/dist/archive-limits.js +18 -0
- package/dist/archive-native.d.ts +18 -0
- package/dist/archive-native.d.ts.map +1 -0
- package/dist/archive-native.js +115 -0
- package/dist/archive-options.d.ts +21 -0
- package/dist/archive-options.d.ts.map +1 -0
- package/dist/archive-options.js +1 -0
- package/dist/archive-policy.d.ts +20 -0
- package/dist/archive-policy.d.ts.map +1 -0
- package/dist/archive-policy.js +29 -0
- package/dist/archive-read.d.ts +6 -0
- package/dist/archive-read.d.ts.map +1 -0
- package/dist/archive-read.js +200 -0
- package/dist/archive-staging.d.ts +2 -5
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +19 -10
- package/dist/archive-tar-meta.d.ts +6 -0
- package/dist/archive-tar-meta.d.ts.map +1 -0
- package/dist/archive-tar-meta.js +143 -0
- package/dist/archive-tar-runtime.d.ts +43 -0
- package/dist/archive-tar-runtime.d.ts.map +1 -0
- package/dist/archive-tar-runtime.js +8 -0
- package/dist/archive-tar.d.ts +5 -1
- package/dist/archive-tar.d.ts.map +1 -1
- package/dist/archive-tar.js +24 -6
- package/dist/archive-zip-entry.d.ts +15 -0
- package/dist/archive-zip-entry.d.ts.map +1 -0
- package/dist/archive-zip-entry.js +17 -0
- package/dist/archive.d.ts +7 -17
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +111 -108
- package/dist/atomic.d.ts +1 -1
- package/dist/atomic.d.ts.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -1
- package/dist/containment.d.ts +2 -0
- package/dist/containment.d.ts.map +1 -0
- package/dist/containment.js +1 -0
- package/dist/durability.d.ts +2 -0
- package/dist/durability.d.ts.map +1 -1
- package/dist/durability.js +2 -0
- package/dist/errors.d.ts +4 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -0
- package/dist/file-hash.d.ts +10 -0
- package/dist/file-hash.d.ts.map +1 -0
- package/dist/file-hash.js +73 -0
- package/dist/file-lock-sync.d.ts +35 -0
- package/dist/file-lock-sync.d.ts.map +1 -0
- package/dist/file-lock-sync.js +233 -0
- package/dist/file-lock.d.ts +2 -0
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +1 -0
- package/dist/filename.d.ts.map +1 -1
- package/dist/filename.js +4 -1
- package/dist/guarded-mkdir.d.ts +7 -1
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +45 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +30 -15
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +101 -17
- package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
- package/dist/native/darwin-x64/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
- package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
- package/dist/native-binding.d.ts +79 -0
- package/dist/native-binding.d.ts.map +1 -0
- package/dist/native-binding.js +1 -0
- package/dist/native-config.d.ts +17 -0
- package/dist/native-config.d.ts.map +1 -0
- package/dist/native-config.js +71 -0
- package/dist/native-operations.d.ts +21 -0
- package/dist/native-operations.d.ts.map +1 -0
- package/dist/native-operations.js +115 -0
- package/dist/native-pinned-write.d.ts +5 -0
- package/dist/native-pinned-write.d.ts.map +1 -0
- package/dist/native-pinned-write.js +115 -0
- package/dist/native.d.ts +14 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +204 -0
- package/dist/output-sibling.d.ts +8 -0
- package/dist/output-sibling.d.ts.map +1 -0
- package/dist/output-sibling.js +122 -0
- package/dist/output.d.ts +2 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +32 -5
- package/dist/owner-dacl.d.ts +31 -0
- package/dist/owner-dacl.d.ts.map +1 -0
- package/dist/owner-dacl.js +27 -0
- package/dist/path-policy.js +1 -1
- package/dist/permissions-public.d.ts +2 -0
- package/dist/permissions-public.d.ts.map +1 -1
- package/dist/permissions-public.js +2 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +6 -0
- package/dist/pinned-operation.d.ts +2 -0
- package/dist/pinned-operation.d.ts.map +1 -0
- package/dist/pinned-operation.js +35 -0
- package/dist/pinned-write.d.ts +3 -16
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +20 -85
- package/dist/private-directory.d.ts +5 -0
- package/dist/private-directory.d.ts.map +1 -0
- package/dist/private-directory.js +13 -0
- package/dist/private-temp-workspace.d.ts +6 -2
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +40 -13
- package/dist/publish-file-failure.d.ts +27 -0
- package/dist/publish-file-failure.d.ts.map +1 -0
- package/dist/publish-file-failure.js +21 -0
- package/dist/publish-file.d.ts +21 -0
- package/dist/publish-file.d.ts.map +1 -0
- package/dist/publish-file.js +351 -0
- package/dist/read-opened-file.d.ts +3 -0
- package/dist/read-opened-file.d.ts.map +1 -1
- package/dist/read-opened-file.js +1 -0
- package/dist/replace-file-copy-fallback.d.ts +34 -0
- package/dist/replace-file-copy-fallback.d.ts.map +1 -0
- package/dist/replace-file-copy-fallback.js +339 -0
- package/dist/replace-file.d.ts +6 -2
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +39 -90
- package/dist/root-context.d.ts +4 -1
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-context.js +20 -1
- package/dist/root-impl.d.ts +5 -0
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +128 -225
- package/dist/root-path-existing.d.ts +3 -0
- package/dist/root-path-existing.d.ts.map +1 -0
- package/dist/root-path-existing.js +69 -0
- package/dist/root-path.d.ts +1 -2
- package/dist/root-path.d.ts.map +1 -1
- package/dist/root-path.js +67 -81
- package/dist/root-walk.d.ts +41 -0
- package/dist/root-walk.d.ts.map +1 -0
- package/dist/root-walk.js +114 -0
- package/dist/root.d.ts +2 -0
- package/dist/root.d.ts.map +1 -1
- package/dist/secret-file.d.ts +5 -2
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +20 -2
- package/dist/secret-read-async.d.ts +4 -0
- package/dist/secret-read-async.d.ts.map +1 -0
- package/dist/secret-read-async.js +95 -0
- package/dist/secret.d.ts +2 -1
- package/dist/secret.d.ts.map +1 -1
- package/dist/secret.js +2 -1
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +1 -0
- package/dist/sidecar-lock-handle.d.ts +20 -0
- package/dist/sidecar-lock-handle.d.ts.map +1 -0
- package/dist/sidecar-lock-handle.js +28 -0
- package/dist/sidecar-lock-policy.d.ts +10 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -0
- package/dist/sidecar-lock-policy.js +29 -0
- package/dist/sidecar-lock-reclaim.d.ts +22 -6
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +106 -20
- package/dist/sidecar-lock-types.d.ts +56 -0
- package/dist/sidecar-lock-types.d.ts.map +1 -0
- package/dist/sidecar-lock-types.js +1 -0
- package/dist/sidecar-lock.d.ts +2 -45
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +94 -55
- package/dist/temp-cleanup.d.ts +7 -1
- package/dist/temp-cleanup.d.ts.map +1 -1
- package/dist/temp-cleanup.js +35 -5
- package/dist/temp.d.ts +2 -1
- package/dist/temp.d.ts.map +1 -1
- package/dist/test-hooks.d.ts +3 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/windows-permissions-native.d.ts +9 -0
- package/dist/windows-permissions-native.d.ts.map +1 -0
- package/dist/windows-permissions-native.js +31 -0
- package/docs/archive.md +131 -13
- package/docs/atomic.md +30 -1
- package/docs/config.md +51 -21
- package/docs/durability.md +211 -1
- package/docs/errors.md +36 -6
- package/docs/filename.md +0 -0
- package/docs/index.md +11 -8
- package/docs/install.md +27 -26
- package/docs/json-store.md +18 -3
- package/docs/migrating-to-0.5.md +196 -0
- package/docs/native-helper.md +85 -0
- package/docs/native.md +134 -0
- package/docs/output.md +37 -11
- package/docs/path.md +1 -1
- package/docs/permissions.md +84 -1
- package/docs/quickstart.md +5 -2
- package/docs/reading.md +4 -4
- package/docs/root.md +31 -15
- package/docs/secret-file.md +50 -1
- package/docs/security-model.md +29 -8
- package/docs/sidecar-lock.md +92 -7
- package/docs/temp.md +24 -1
- package/docs/test-hooks.md +22 -2
- package/docs/testing.md +18 -7
- package/docs/types.md +3 -1
- package/docs/walk.md +55 -0
- package/docs/writing.md +3 -3
- package/package.json +8 -2
- package/dist/pinned-helper.d.ts +0 -9
- package/dist/pinned-helper.d.ts.map +0 -1
- package/dist/pinned-helper.js +0 -19
- package/dist/pinned-path.d.ts +0 -7
- package/dist/pinned-path.d.ts.map +0 -1
- package/dist/pinned-path.js +0 -21
- package/dist/pinned-python-config.d.ts +0 -9
- package/dist/pinned-python-config.d.ts.map +0 -1
- package/dist/pinned-python-config.js +0 -38
- package/dist/pinned-python.d.ts +0 -12
- package/dist/pinned-python.d.ts.map +0 -1
- package/dist/pinned-python.js +0 -688
- package/docs/python-helper.md +0 -103
package/docs/native.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Native architecture
|
|
3
|
+
description: "The bundled native bindings, fd-relative beneath model, platform mechanisms, loader security, and JavaScript fallback contract."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Native architecture
|
|
7
|
+
|
|
8
|
+
`@openclaw/fs-safe` bundles native bindings that supply mechanisms Node does
|
|
9
|
+
not expose directly. The Rust layer is deliberately not a second policy engine.
|
|
10
|
+
TypeScript owns trusted-root selection, path validation, archive filtering,
|
|
11
|
+
budgets, modes, identity fencing, cleanup decisions, and error normalization.
|
|
12
|
+
Rust receives already-decided relative operations and performs the smallest
|
|
13
|
+
platform syscall sequence that can preserve the boundary.
|
|
14
|
+
|
|
15
|
+
Every operation that has an equivalent safe Node implementation keeps that
|
|
16
|
+
guarded JavaScript path. Native loading is lazy; installs do not compile Rust,
|
|
17
|
+
run postinstall code, or fetch binaries. The npm tarball carries all seven
|
|
18
|
+
supported targets, so it is larger than a per-platform package by design.
|
|
19
|
+
Native-only formats and creation-time Windows DACL guarantees fail explicitly
|
|
20
|
+
instead of substituting a weaker implementation.
|
|
21
|
+
|
|
22
|
+
## The beneath model
|
|
23
|
+
|
|
24
|
+
A trusted directory descriptor is the capability. Native operations accept
|
|
25
|
+
that descriptor plus a validated relative path and never reconstruct authority
|
|
26
|
+
from a process working directory. Newly created files use exclusive creation,
|
|
27
|
+
and TypeScript compares descriptor, pathname, and expected identities before
|
|
28
|
+
accepting results.
|
|
29
|
+
|
|
30
|
+
Conceptually, a caller grants authority to an already-open root—not to a path
|
|
31
|
+
string that can be reinterpreted later:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
validated Root handle
|
|
35
|
+
└─ relative components (untrusted)
|
|
36
|
+
└─ open/link/mkdir beneath the handle
|
|
37
|
+
└─ compare descriptor + pathname + expected identity
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The TypeScript layer validates and decides. The native layer never decides
|
|
41
|
+
whether a path, archive entry, mode, owner, or cleanup policy is acceptable.
|
|
42
|
+
|
|
43
|
+
- Linux uses `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`, fd-relative
|
|
44
|
+
`mkdirat`/`linkat`/`renameat2`, `FICLONE`, and `copy_file_range`.
|
|
45
|
+
- macOS 15.4 and newer first use `openat(O_RESOLVE_BENEATH)`; older kernels walk
|
|
46
|
+
components with `openat(O_NOFOLLOW)` and restart in-root symlinks from the
|
|
47
|
+
pinned root. Both routes apply an `F_GETPATH` post-open containment detector,
|
|
48
|
+
but directory rename races mean the result remains `best-effort`, not
|
|
49
|
+
race-atomic. macOS uses `renameatx_np(RENAME_EXCL)` and permits
|
|
50
|
+
`fclonefileat` in an owned, non-shared parent. The clone is normalized inside
|
|
51
|
+
a private staging directory: flags, ACLs, extended attributes, and broad mode
|
|
52
|
+
bits are cleared before no-replace publication.
|
|
53
|
+
- Windows uses handle-relative `NtCreateFile` with `OBJ_DONT_REPARSE` and
|
|
54
|
+
`FILE_OPEN_REPARSE_POINT`, then explicitly rejects reparse points. Rename and
|
|
55
|
+
hardlink operations stay rooted in already-open handles. Owner/DACL reads
|
|
56
|
+
use `GetSecurityInfo`; private directories receive their protected DACL in
|
|
57
|
+
the `CreateDirectoryW` call itself.
|
|
58
|
+
|
|
59
|
+
## Archives
|
|
60
|
+
|
|
61
|
+
Rust streams ZIP and TAR payloads, including gzip, zstd, and bzip2. It first
|
|
62
|
+
returns a bounded manifest. TypeScript applies the shared path, filter, strip,
|
|
63
|
+
mode, and byte policies and returns an index-bound extraction plan. Rust then
|
|
64
|
+
creates only those planned entries beneath a private staging descriptor.
|
|
65
|
+
|
|
66
|
+
A fixed-512-byte pass-through meter sits between decompression and the TAR
|
|
67
|
+
crate. It reads only header type and octal/base-256 size fields. It never parses
|
|
68
|
+
metadata content. Oversized GNU long-name/link metadata is rejected before
|
|
69
|
+
buffering; PAX size overrides and GNU sparse entries are rejected as
|
|
70
|
+
unmeterable rather than guessed. The JavaScript node-tar path receives the same
|
|
71
|
+
`maxMetaEntryBytes` value and a matching fixed-header preflight.
|
|
72
|
+
|
|
73
|
+
## Publication and hashing
|
|
74
|
+
|
|
75
|
+
Exclusive publication tries a hardlink, then a copy-on-write clone, Linux
|
|
76
|
+
`copy_file_range`, and finally the existing asynchronous JavaScript byte loop.
|
|
77
|
+
All routes preserve `wx` semantics and the same source/target identity and
|
|
78
|
+
SHA-256 fencing. Native hashing and Linux whole-file copying run on N-API async
|
|
79
|
+
workers rather than the JavaScript event loop.
|
|
80
|
+
|
|
81
|
+
## Mode semantics
|
|
82
|
+
|
|
83
|
+
| Mode | Native loading | Fallback |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `auto` | Try once, cache the result | Use guarded JavaScript when unavailable |
|
|
86
|
+
| `require` | Try once, cache the result | Throw `FsSafeError("helper-unavailable")` |
|
|
87
|
+
| `off` | Never attempt a binding load | Always use guarded JavaScript |
|
|
88
|
+
|
|
89
|
+
The one exception is functionality with no safe JavaScript implementation:
|
|
90
|
+
zstd/bzip2 TAR and Windows private-directory creation fail with
|
|
91
|
+
`helper-unavailable` when native support is absent or off.
|
|
92
|
+
|
|
93
|
+
## JavaScript fallback guarantees and delta
|
|
94
|
+
|
|
95
|
+
Public policy does not change with the selected mechanism: traversal and link
|
|
96
|
+
rejection, archive filters/limits/modes, exclusive target creation, source and
|
|
97
|
+
target identity fencing, publication cleanup receipts, and secret/lock policy
|
|
98
|
+
remain TypeScript-owned. What changes is the syscall strength or availability:
|
|
99
|
+
|
|
100
|
+
| Capability | Native path | Guarded JavaScript path |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| Root-relative opens/mutations | Descriptor-relative beneath operations. Linux reports `kernel-atomic`; macOS and Windows report `best-effort`. macOS uses `O_RESOLVE_BENEATH` when available plus an `F_GETPATH` detector, while Windows rejects reparse traversal in the object-manager call. | Reports `best-effort`: component-wise alias checks, no-follow opens where Node exposes them, private temp/rename, and post-operation identity verification. A hostile same-UID peer has a wider pathname race window. |
|
|
103
|
+
| ZIP/TAR/gzip | Rust streaming decode and fd-relative output creation. | JSZip/node-tar into a private stage, then the same guarded merge policy. |
|
|
104
|
+
| Zstd/bzip2 TAR | Supported. | Unsupported; typed `helper-unavailable`. |
|
|
105
|
+
| Publication copy | Clone, Linux `copy_file_range`, async native SHA-256. | Exclusive `wx` byte loop and Node SHA-256 with the same content/identity fences. |
|
|
106
|
+
| `rename-noreplace` | Atomic platform no-replace rename. | Unsupported; no emulation by check-then-rename. |
|
|
107
|
+
| Windows DACL read | Direct `GetSecurityInfo`; the public facts API exposes ordered basic allow/deny ACE SIDs, masks, and decoded flags without trust policy. | Established .NET/`icacls` inspection fallback for coarse permission checks; raw ACE facts are native-only. |
|
|
108
|
+
| Windows private directory | Creation-time protected DACL. | Unsupported; no weaker pathname-only substitute. |
|
|
109
|
+
|
|
110
|
+
Use `off` in CI to keep the fallback contract exercised. Use `require` when a
|
|
111
|
+
deployment depends on the stronger mechanism or a native-only feature; do not
|
|
112
|
+
infer native loading from timing.
|
|
113
|
+
|
|
114
|
+
## Loader security
|
|
115
|
+
|
|
116
|
+
Importing fs-safe never executes a child process. Linux libc selection uses
|
|
117
|
+
the Node process report, conventional musl library filenames, and the ELF
|
|
118
|
+
`PT_INTERP` field of `process.execPath`. If all probes are inconclusive, the
|
|
119
|
+
loader conservatively attempts the bundled glibc binary and lets normal module
|
|
120
|
+
loading fail into `auto` fallback. The loader requires only
|
|
121
|
+
`dist/native/<target>/fs-safe-native.node`; it never probes optional packages,
|
|
122
|
+
downloads code, or runs a postinstall step. A missing or incompatible binary
|
|
123
|
+
silently selects the JavaScript fallback in `auto`, throws typed
|
|
124
|
+
`helper-unavailable` in `require`, and is never inspected in `off`. Tests reject
|
|
125
|
+
`child_process`, `exec`, or `spawn` usage in the loader.
|
|
126
|
+
|
|
127
|
+
## Related pages
|
|
128
|
+
|
|
129
|
+
- [Native helper policy](native-helper.md)
|
|
130
|
+
- [Security model](security-model.md)
|
|
131
|
+
- [Archive extraction](archive.md)
|
|
132
|
+
- [Durability](durability.md)
|
|
133
|
+
- [Permissions](permissions.md)
|
|
134
|
+
- [Migrating to 0.5](migrating-to-0.5.md)
|
package/docs/output.md
CHANGED
|
@@ -16,10 +16,10 @@ await writeExternalFileWithinRoot({
|
|
|
16
16
|
});
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
The external writer
|
|
20
|
-
private
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
package/docs/permissions.md
CHANGED
|
@@ -64,7 +64,88 @@ createIcaclsResetCommand(targetPath, { isDir, env });
|
|
|
64
64
|
resolveWindowsUserPrincipal(env);
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
The
|
|
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.
|
package/docs/quickstart.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
124
|
+
A symlink swap between `exists` and `readText` is checked again by the read; the boundary and its documented race window are per-call.
|
|
125
125
|
|
|
126
126
|
## Streaming patterns
|
|
127
127
|
|
package/docs/root.md
CHANGED
|
@@ -44,15 +44,30 @@ Every method on the returned handle accepts paths relative to the root and rejec
|
|
|
44
44
|
### Reads
|
|
45
45
|
|
|
46
46
|
```ts
|
|
47
|
-
fs.read(rel, options?) // { buffer, realPath, stat }
|
|
47
|
+
fs.read(rel, options?) // { buffer, containment, realPath, stat }
|
|
48
48
|
fs.readBytes(rel, options?) // Buffer
|
|
49
49
|
fs.readText(rel, options?) // string
|
|
50
50
|
fs.readJson<T>(rel, options?) // parsed T
|
|
51
|
-
fs.open(rel, options?) // { handle, realPath, stat, [Symbol.asyncDispose] }
|
|
51
|
+
fs.open(rel, options?) // { handle, containment, realPath, stat, [Symbol.asyncDispose] }
|
|
52
52
|
fs.readAbsolute(absPath, options?) // ReadResult; absPath must already be inside the root
|
|
53
53
|
fs.reader(options?) // (path) => Promise<Buffer>; useful for loader APIs
|
|
54
|
+
fs.walk(rel, options) // root-bounded AsyncIterable<{ relativePath, kind, size }>
|
|
54
55
|
```
|
|
55
56
|
|
|
57
|
+
`walk()` is the incremental, root-bounded recursive scan. It supports entry and
|
|
58
|
+
depth budgets, cancellation, and `symlinkPolicy: "skip" |
|
|
59
|
+
"follow-within-root"`. Budget exhaustion yields a `"truncated"` marker by
|
|
60
|
+
default or throws `FsSafeError("too-large")` with `limitBehavior: "throw"`.
|
|
61
|
+
Use `entryFilter(entry)` to return `"include"`, `"skip"`, or
|
|
62
|
+
`"skip-subtree"`. `"skip"` omits the current entry but still descends into a
|
|
63
|
+
directory; `"skip-subtree"` omits a directory and all of its descendants.
|
|
64
|
+
Directory reads remain fail-fast by default. With
|
|
65
|
+
`onDirectoryError: "skip-and-report"`, the iterator instead yields
|
|
66
|
+
`{ relativePath, kind: "directory-error", size: 0, error }` and continues with
|
|
67
|
+
the remaining tree.
|
|
68
|
+
See [Directory walking](walk.md) for the pure-Node guarantees and the contrast
|
|
69
|
+
with the standalone best-effort walkers.
|
|
70
|
+
|
|
56
71
|
`open()` returns a Node `FileHandle` for streaming. Prefer `await using` for cleanup:
|
|
57
72
|
|
|
58
73
|
```ts
|
|
@@ -64,6 +79,10 @@ await using opened = await fs.open("large.log");
|
|
|
64
79
|
}
|
|
65
80
|
```
|
|
66
81
|
|
|
82
|
+
`open()`, `read()`, and `openWritable()` results include
|
|
83
|
+
`containment: "best-effort"`. The field reports the mechanism used; see the
|
|
84
|
+
[security model](security-model.md#containment-guarantees-by-platform).
|
|
85
|
+
|
|
67
86
|
### Writes
|
|
68
87
|
|
|
69
88
|
```ts
|
|
@@ -100,25 +119,22 @@ fs.resolve(rel) // absolute path inside the root, after canonic
|
|
|
100
119
|
|
|
101
120
|
These do not pin a later operation. They are safe to expose to UIs and decision points; for the actual read or write, use the verb methods so the operation pins identity at the point of use.
|
|
102
121
|
|
|
103
|
-
##
|
|
122
|
+
## Native helper mode
|
|
104
123
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 {
|
|
129
|
+
import { configureFsSafeNative } from "@openclaw/fs-safe/config";
|
|
112
130
|
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
118
|
-
helper
|
|
119
|
-
|
|
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
|
|
package/docs/secret-file.md
CHANGED
|
@@ -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.
|
package/docs/security-model.md
CHANGED
|
@@ -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`.
|
|
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
|
|
65
|
+
`resolve()`, `exists()`, `stat()`, and `list()` are explicitly advisory — they answer a question and return. To act on a path with operation-local identity checks, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()`. The containment table below states which opens are kernel-atomic and which remain best-effort.
|
|
53
66
|
|
|
54
67
|
### Denied mutations
|
|
55
68
|
|
|
@@ -84,12 +97,20 @@ A library cannot revoke its own caller's authority. If your code chooses to bypa
|
|
|
84
97
|
|
|
85
98
|
The library does not modify or constrain the global Node.js `fs` namespace, and it does not patch the runtime. Other code in the same process retains its normal filesystem authority.
|
|
86
99
|
|
|
87
|
-
##
|
|
100
|
+
## Containment guarantees by platform
|
|
101
|
+
|
|
102
|
+
`openBeneath()` and JavaScript open results report one of two factual containment classes:
|
|
103
|
+
|
|
104
|
+
| Mechanism | Reported containment | Boundary |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| Linux native | `kernel-atomic` | `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` resolves and opens under the root in one kernel operation. |
|
|
107
|
+
| macOS native | `best-effort` | macOS 15.4 and newer use `O_RESOLVE_BENEATH` first; older kernels use the guarded `openat(O_NOFOLLOW)` component walk. Both verify the opened descriptor with `F_GETPATH`. |
|
|
108
|
+
| Windows native | `best-effort` | Handle-relative `NtCreateFile` rejects reparse points, but this package does not claim a Linux-style atomic beneath guarantee. |
|
|
109
|
+
| JavaScript fallback | `best-effort` | Canonical checks, no-follow opens where Node exposes them, and post-open identity checks form a check-then-use sequence. |
|
|
88
110
|
|
|
89
|
-
|
|
90
|
-
- **Windows:** Falls back to the safest Node-level behavior available. `O_NOFOLLOW` is not honored. Some fd-relative POSIX hardening is unavailable. The library does the path canonicalization, identity, and atomic-rename checks it can.
|
|
111
|
+
The macOS `F_GETPATH` verification is an escape detector, not a race-atomic guarantee. A hostile same-UID process can rename a directory after `O_RESOLVE_BENEATH` or the manual walk and race the post-open sample or a later descriptor-relative mutation. The native result therefore remains `best-effort` on macOS even when the kernel flag is available. No policy decision is attached to these labels; callers can inspect the fact and decide what their own threat model requires.
|
|
91
112
|
|
|
92
|
-
The
|
|
113
|
+
The public `OpenResult`, `ReadResult`, and `WritableOpenResult` expose `containment`. Those root APIs currently report `best-effort`; direct native `openBeneath()` reports the platform value above. No-replace publication uses `renameat2(RENAME_NOREPLACE)` on Linux, `renameatx_np(RENAME_EXCL)` on macOS, and `FileRenameInfoEx` with replacement disabled on Windows, but those separate mutation semantics do not upgrade an open result's containment label.
|
|
93
114
|
|
|
94
115
|
## Limitations to keep in mind
|
|
95
116
|
|
|
@@ -98,11 +119,11 @@ The library does not advertise different security guarantees per platform — it
|
|
|
98
119
|
| Not ambient authority removal | Code that can import `node:fs` can still bypass the handle. Keep caller-controlled path operations behind `root()` by convention, review, and tests. |
|
|
99
120
|
| Absolute paths are escape hatches | APIs that accept or return absolute paths exist for audit, ingest, and advanced composition. Prefer root-relative names in normal application flow. |
|
|
100
121
|
| Not a mount boundary | `root()` keeps path traversal inside the directory tree and blocks known unsafe read device paths, but it does not make bind mounts or virtual filesystems safe to expose wholesale. |
|
|
101
|
-
| Per-call, not per-session | Another process with the same privileges can still mutate the tree between
|
|
122
|
+
| Per-call, not per-session | Another process with the same privileges can still mutate the tree between calls, and best-effort mechanisms retain documented same-call race windows. Use one verb method to minimize the window and inspect its reported containment class. |
|
|
102
123
|
| Hardlink rejection is best-effort | Link-count checks depend on platform metadata. Treat `hardlinks: "reject"` as a tripwire, not an authorization primitive. |
|
|
103
124
|
| Mode bits are not a full policy engine | `replaceFileAtomic` and secret-file helpers set requested modes, but you should still set umask and inspect modes when policy requires it. |
|
|
104
125
|
| Archive extraction is path safety, not content safety | Unsafe entry paths and links are rejected; malicious payload contents remain your application layer's problem. |
|
|
105
|
-
|
|
|
126
|
+
| Native package unavailable | `helper-unavailable` falls back in `auto` mode and fails closed in `require` mode for native-backed operations. Guarded JavaScript atomicity and identity checks remain. |
|
|
106
127
|
| FUSE mounts with rename-unstable inode numbers | Some FUSE mounts (rclone is a confirmed example) do not preserve source inode identity at the rename destination. The explicit `renameIdentity: "verify-content-with-lock"` compatibility mode verifies content under a cooperative lock for that boundary only; subsequent path identity checks and the default remain strict. See [Writing](writing.md) for the weaker opt-in contract. |
|
|
107
128
|
|
|
108
129
|
## Recommended deployment shape
|