@openclaw/fs-safe 0.2.7 → 0.4.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 (59) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +18 -5
  3. package/dist/advanced.d.ts +1 -0
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +1 -0
  6. package/dist/deny-mutations.d.ts +11 -0
  7. package/dist/deny-mutations.d.ts.map +1 -0
  8. package/dist/deny-mutations.js +102 -0
  9. package/dist/device-path.d.ts +13 -0
  10. package/dist/device-path.d.ts.map +1 -0
  11. package/dist/device-path.js +112 -0
  12. package/dist/errors.d.ts +1 -1
  13. package/dist/errors.d.ts.map +1 -1
  14. package/dist/fsync.d.ts +2 -0
  15. package/dist/fsync.d.ts.map +1 -0
  16. package/dist/fsync.js +21 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/json.d.ts.map +1 -1
  20. package/dist/json.js +51 -4
  21. package/dist/move-path.d.ts +3 -0
  22. package/dist/move-path.d.ts.map +1 -1
  23. package/dist/move-path.js +13 -1
  24. package/dist/opened-realpath.d.ts +3 -0
  25. package/dist/opened-realpath.d.ts.map +1 -0
  26. package/dist/opened-realpath.js +79 -0
  27. package/dist/path.d.ts +1 -0
  28. package/dist/path.d.ts.map +1 -1
  29. package/dist/path.js +1 -0
  30. package/dist/pinned-open.d.ts.map +1 -1
  31. package/dist/pinned-open.js +7 -0
  32. package/dist/pinned-write.d.ts.map +1 -1
  33. package/dist/pinned-write.js +3 -0
  34. package/dist/read-opened-file.d.ts +18 -0
  35. package/dist/read-opened-file.d.ts.map +1 -0
  36. package/dist/read-opened-file.js +15 -0
  37. package/dist/regular-file.d.ts.map +1 -1
  38. package/dist/regular-file.js +26 -3
  39. package/dist/root-impl.d.ts +18 -15
  40. package/dist/root-impl.d.ts.map +1 -1
  41. package/dist/root-impl.js +110 -147
  42. package/dist/root.d.ts +1 -1
  43. package/dist/root.d.ts.map +1 -1
  44. package/dist/secure-file.d.ts.map +1 -1
  45. package/dist/secure-file.js +2 -0
  46. package/dist/walk.d.ts +13 -2
  47. package/dist/walk.d.ts.map +1 -1
  48. package/dist/walk.js +29 -6
  49. package/docs/contributing.md +1 -1
  50. package/docs/errors.md +5 -0
  51. package/docs/install.md +3 -3
  52. package/docs/path.md +13 -0
  53. package/docs/reading.md +6 -4
  54. package/docs/root.md +15 -5
  55. package/docs/security-model.md +7 -2
  56. package/docs/types.md +22 -9
  57. package/docs/walk.md +11 -1
  58. package/docs/writing.md +21 -3
  59. package/package.json +22 -17
package/docs/install.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Install
2
2
 
3
- `fs-safe` is published to npm as `@openclaw/fs-safe`. It targets Node 20.11 or newer, ships ESM only, and works on macOS, Linux, and Windows.
3
+ `fs-safe` is published to npm as `@openclaw/fs-safe`. It targets Node 22 or newer, ships ESM only, and works on macOS, Linux, and Windows.
4
4
 
5
5
  ## Package managers
6
6
 
@@ -22,13 +22,13 @@ bun add @openclaw/fs-safe
22
22
 
23
23
  ## Node version
24
24
 
25
- Minimum **Node 20.11**. The package uses `fs.promises`, `fs.constants.O_NOFOLLOW` where available, and `node:stream/promises`. Earlier Node releases will fail at import time.
25
+ Minimum **Node 22**. The package uses `fs.promises`, `fs.constants.O_NOFOLLOW` where available, and `node:stream/promises`. Earlier Node releases will fail at import time.
26
26
 
27
27
  Verify the runtime:
28
28
 
29
29
  ```bash
30
30
  node --version
31
- # v20.11.0 or newer
31
+ # v22.0.0 or newer
32
32
  ```
33
33
 
34
34
  ## TypeScript
package/docs/path.md CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  safeRealpathSync,
12
12
  safeStatSync,
13
13
  assertNoNulPathInput,
14
+ assertNoUnsafeDeviceReadPath,
15
+ isUnsafeDeviceReadPath,
14
16
  isNotFoundPathError,
15
17
  isSymlinkOpenError,
16
18
  hasNodeErrorCode,
@@ -89,6 +91,17 @@ if (!stat?.isFile()) return notFound();
89
91
 
90
92
  Throws `FsSafeError` with code `invalid-path` when a path string contains an embedded NUL byte. Use it before calling Node `fs` APIs directly; Node's native error can include raw path text in the message.
91
93
 
94
+ ### `assertNoUnsafeDeviceReadPath(filePath, options?)`
95
+
96
+ Throws `FsSafeError` with code `device-path` when a read target is a known unsafe device or process-fd path. The built-in read/open helpers call this automatically before opening files; use it only when you are building your own read primitive.
97
+
98
+ ```ts
99
+ assertNoUnsafeDeviceReadPath("/dev/zero"); // throws on POSIX
100
+ isUnsafeDeviceReadPath("/dev/fd/0"); // true on POSIX
101
+ ```
102
+
103
+ The check is intentionally not a normal consumer policy knob. Safe read APIs reject these targets by default because they can block forever, stream indefinitely, or alias process file descriptors.
104
+
92
105
  ## Error inspection
93
106
 
94
107
  ### `isNotFoundPathError(err)`
package/docs/reading.md CHANGED
@@ -17,10 +17,11 @@ Regardless of shape, every read goes through the same boundary checks:
17
17
  1. Resolve the relative path against the canonical real root.
18
18
  2. Reject anything that escapes the root (`outside-workspace`).
19
19
  3. Reject `..` segments and absolute inputs (unless via `readAbsolute` with an in-root absolute path).
20
- 4. Open with `O_NOFOLLOW` where available. A symlink in the path triggers `symlink` unless the call's `symlinks` policy is `follow-within-root`.
21
- 5. Stat the open fd and compare to the resolved path's identity (`sameFileIdentity`). A swap mid-call triggers `path-mismatch`.
22
- 6. If `hardlinks: "reject"`, refuse files with `nlink > 1` (`hardlink`).
23
- 7. If `maxBytes` is set, refuse reads larger than the cap (`too-large`).
20
+ 4. Reject known unsafe device and process-fd paths before opening (`device-path`).
21
+ 5. Open with `O_NOFOLLOW` where available. A symlink in the path triggers `symlink` unless the call's `symlinks` policy is `follow-within-root`.
22
+ 6. Stat the open fd and compare to the resolved path's identity (`sameFileIdentity`). A swap mid-call triggers `path-mismatch`.
23
+ 7. If `hardlinks: "reject"`, refuse files with `nlink > 1` (`hardlink`).
24
+ 8. If `maxBytes` is set, refuse reads larger than the cap (`too-large`).
24
25
 
25
26
  ## Read shapes
26
27
 
@@ -160,6 +161,7 @@ try {
160
161
  - **`outside-workspace`** — relative path escaped the root, or `readAbsolute` got an absolute path outside.
161
162
  - **`not-found`** — the file is gone.
162
163
  - **`not-file`** — you read a directory or a non-regular file (FIFO, socket, …).
164
+ - **`device-path`** — the path targets a known unsafe device or process fd path.
163
165
  - **`symlink`** — a path component is a symlink and the policy is `reject`.
164
166
  - **`path-mismatch`** — opened fd identity did not match the resolved path. Almost always a TOCTOU swap by something else.
165
167
  - **`hardlink`** — `hardlinks: "reject"` saw `nlink > 1`.
package/docs/root.md CHANGED
@@ -19,17 +19,23 @@ function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;
19
19
 
20
20
  type RootDefaults = {
21
21
  hardlinks?: "reject" | "allow"; // refuse files with nlink > 1 on read; defaults to "reject"
22
+ denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
22
23
  maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
23
24
  mkdir?: boolean; // create missing parent dirs on write/openWritable/append
24
25
  mode?: number; // file mode applied to new writes; per-call override available
25
26
  nonBlockingRead?: boolean; // schedule reads on a worker; useful for large files
26
27
  symlinks?: "reject" | "follow-within-root"; // policy when a path component is a symlink
27
28
  };
29
+
30
+ type DenyMutationPolicy = {
31
+ paths?: readonly string[];
32
+ prefixes?: readonly string[];
33
+ };
28
34
  ```
29
35
 
30
36
  `root()` resolves the directory through the real filesystem. A symlinked input becomes the canonical path; a non-existent root throws `FsSafeError` with code `not-found`, and malformed or non-directory roots throw `invalid-path`.
31
37
 
32
- `defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only.
38
+ `defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only, except `denyMutations`: root and per-call deny entries are merged so a call cannot clear a root-level deny.
33
39
 
34
40
  ## The `Root` interface
35
41
 
@@ -65,13 +71,13 @@ fs.write(rel, data, options?) // overwrite-ok atomic write
65
71
  fs.create(rel, data, options?) // throws "already-exists" if target exists
66
72
  fs.writeJson(rel, value, options?) // JSON.stringify + atomic write
67
73
  fs.createJson(rel, value, options?) // create() variant of writeJson
68
- fs.append(rel, data, options?) // append text/buffer; respects mkdir default
74
+ fs.append(rel, data, options?) // append text/buffer; syncs before close
69
75
  fs.copyIn(rel, sourceAbsPath, options?) // copy from outside the root, atomically, with size cap
70
76
  fs.openWritable(rel, options?) // FileHandle for streaming writes; supports await using
71
77
  fs.move(from, to, options?) // rename within the root; defaults to no clobber
72
- fs.remove(rel) // unlink file or rmdir empty directory
73
- fs.mkdir(rel) // mkdir -p (creates missing parents)
74
- fs.ensureRoot() // accepts "" / "." as the root itself
78
+ fs.remove(rel, options?) // unlink file or rmdir empty directory
79
+ fs.mkdir(rel, options?) // mkdir -p (creates missing parents)
80
+ fs.ensureRoot(options?) // accepts "" / "." as the root itself
75
81
  ```
76
82
 
77
83
  `write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
@@ -80,6 +86,8 @@ fs.ensureRoot() // accepts "" / "." as the root itself
80
86
 
81
87
  `openWritable` opens a writable file with options `mode?: number` and `writeMode?: "replace" | "append" | "update"`. `replace` truncates existing files and is the default; `update` keeps existing contents. Use it for streaming output. Prefer `await using` for cleanup.
82
88
 
89
+ All mutation methods accept `denyMutations?: { paths?: string[]; prefixes?: string[] }`. Entries must be absolute paths. `paths` blocks those exact paths; `prefixes` blocks those paths and their descendants. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a symlinked ancestor to a denied location is still denied. Denied mutations throw `FsSafeError` with code `denied-path`. Use this for caller-specific sensitive paths, not as a replacement for the root boundary, symlink, or hardlink checks.
90
+
83
91
  ### Inspection (advisory)
84
92
 
85
93
  ```ts
@@ -131,7 +139,9 @@ Every method throws `FsSafeError` with a `code`. Branch on `err.code`, not messa
131
139
  | `outside-workspace` | The input resolves outside the root, or contains a `..` segment that would escape it. |
132
140
  | `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). |
133
141
  | `not-file` | A read or copy targeted a non-regular file (directory, FIFO, socket, …). |
142
+ | `device-path` | A read/open target is a known unsafe device or process-fd path. |
134
143
  | `already-exists` | `create()` or `move()` without `overwrite` hit an existing target. |
144
+ | `denied-path` | A mutation target matched `denyMutations.paths` or `denyMutations.prefixes`. |
135
145
  | `symlink` | A path component is a symlink, and the call's `symlinks` policy is `reject`. |
136
146
  | `hardlink` | The target's `nlink > 1` and `hardlinks` policy is `reject`. |
137
147
  | `path-mismatch` | Post-open identity check failed — the opened fd does not match the resolved path. |
@@ -13,6 +13,7 @@ You hand a `root()` boundary to a piece of code that takes caller-controlled rel
13
13
  - replaces a path component with a symlink between check and use (TOCTOU)
14
14
  - replaces the destination directory with a symlink right before a write
15
15
  - creates a hardlink that aliases an out-of-tree inode and asks you to read or replace it
16
+ - asks a read/open primitive to target a known unsafe device or process-fd path
16
17
  - triggers a partial write that leaves a half-written file at the destination
17
18
  - ships an archive with `..` paths, absolute paths, or symlinks pointing outside the destination
18
19
 
@@ -20,7 +21,7 @@ It does **not** defend against:
20
21
 
21
22
  - a process running with permissions to write anywhere on the filesystem and choosing to ignore the library
22
23
  - another process with the same UID racing to mutate the same directory between two separate `fs-safe` calls — the boundary is per-call, not per-session
23
- - traversal across filesystem boundaries, bind mounts, device files, `/proc`-style virtual filesystems, or any other path your process can normally access from inside the root
24
+ - arbitrary traversal across filesystem boundaries, bind mounts, or virtual filesystems beyond the known unsafe read device paths
24
25
  - container escape, TOCTOU between fork and exec of helpers, or kernel-level vulnerabilities
25
26
  - semantic content checks: file types, archive payload schemas, signature verification
26
27
 
@@ -50,6 +51,10 @@ When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink >
50
51
 
51
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.
52
53
 
54
+ ### Denied mutations
55
+
56
+ `denyMutations` is an opt-in application policy for `root()` mutation methods. It blocks exact absolute paths with `paths` and whole subtrees with `prefixes`, merging root defaults with per-call entries so a call cannot clear root-level denies. This is not an OS permission boundary: code with access to `node:fs`, a shell, or another process with the same filesystem privileges can bypass it.
57
+
53
58
  ### Atomic writes
54
59
 
55
60
  `replaceFileAtomic` writes to a sibling temp file in the destination directory, optionally `fsync`s it, optionally `fsync`s the parent directory after rename, and atomically renames over the destination. On failure mid-write, the destination is either the old contents (rename never happened) or the new contents (rename succeeded). There is no half-written intermediate state visible at the destination path.
@@ -79,7 +84,7 @@ The library does not advertise different security guarantees per platform — it
79
84
  |---|---|
80
85
  | 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. |
81
86
  | 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. |
82
- | Not a mount/device boundary | `root()` keeps path traversal inside the directory tree; it does not make device files, bind mounts, or virtual filesystems safe to expose. |
87
+ | 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. |
83
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. |
84
89
  | Hardlink rejection is best-effort | Link-count checks depend on platform metadata. Treat `hardlinks: "reject"` as a tripwire, not an authorization primitive. |
85
90
  | 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. |
package/docs/types.md CHANGED
@@ -82,6 +82,7 @@ type ReadResult = {
82
82
 
83
83
  ```ts
84
84
  type RootDefaults = {
85
+ denyMutations?: DenyMutationPolicy;
85
86
  hardlinks?: "reject" | "allow";
86
87
  maxBytes?: number;
87
88
  mkdir?: boolean;
@@ -90,26 +91,31 @@ type RootDefaults = {
90
91
  symlinks?: "reject" | "follow-within-root";
91
92
  };
92
93
 
94
+ type DenyMutationPolicy = {
95
+ paths?: readonly string[];
96
+ prefixes?: readonly string[];
97
+ };
98
+
93
99
  type RootOptions = {
94
100
  rootDir: string;
95
101
  defaults?: RootDefaults;
96
102
  };
97
103
  ```
98
104
 
99
- `RootDefaults` is what `root(rootDir, defaults)` accepts. See [`root()`](root.md) for the per-method options that override these.
105
+ `RootDefaults` is what `root(rootDir, defaults)` accepts. See [`root()`](root.md) for the per-method options that override these. `denyMutations` is the exception: root and per-call deny entries are merged.
100
106
 
101
107
  ## `RootReadOptions` / `RootWriteOptions` / `RootCopyOptions`
102
108
 
103
109
  ```ts
104
110
  type RootReadOptions = Pick<RootDefaults, "hardlinks" | "maxBytes" | "nonBlockingRead" | "symlinks">;
105
- type RootWriteOptions = Pick<RootDefaults, "mkdir" | "mode"> & {
111
+ type RootWriteOptions = Pick<RootDefaults, "denyMutations" | "mkdir" | "mode"> & {
106
112
  encoding?: BufferEncoding;
107
113
  overwrite?: boolean;
108
114
  };
109
- type RootCopyOptions = Pick<RootDefaults, "maxBytes" | "mkdir" | "mode"> & {
115
+ type RootCopyOptions = Pick<RootDefaults, "denyMutations" | "maxBytes" | "mkdir" | "mode"> & {
110
116
  sourceHardlinks?: "reject" | "allow";
111
117
  };
112
- type RootOpenWritableOptions = Pick<RootDefaults, "mkdir" | "mode"> & {
118
+ type RootOpenWritableOptions = Pick<RootDefaults, "denyMutations" | "mkdir" | "mode"> & {
113
119
  writeMode?: "replace" | "append" | "update";
114
120
  };
115
121
  type RootWriteJsonOptions = RootWriteOptions & {
@@ -120,6 +126,11 @@ type RootWriteJsonOptions = RootWriteOptions & {
120
126
  type RootAppendOptions = RootWriteOptions & {
121
127
  prependNewlineIfNeeded?: boolean;
122
128
  };
129
+ type RootMoveOptions = Pick<RootDefaults, "denyMutations"> & {
130
+ overwrite?: boolean;
131
+ };
132
+ type RootRemoveOptions = Pick<RootDefaults, "denyMutations">;
133
+ type RootMkdirOptions = Pick<RootDefaults, "denyMutations">;
123
134
  ```
124
135
 
125
136
  Per-method option shapes. Each picks the `RootDefaults` keys that apply, plus method-specific extras.
@@ -137,11 +148,13 @@ The two policy unions you'll see throughout. `"reject"` is conservative; `"follo
137
148
 
138
149
  ```ts
139
150
  type FsSafeErrorCode =
140
- | "already-exists" | "hardlink" | "helper-failed" | "helper-unavailable"
141
- | "insecure-permissions" | "invalid-path" | "not-empty" | "not-file"
142
- | "not-found" | "not-owned" | "not-removable" | "outside-workspace"
143
- | "path-alias" | "path-mismatch" | "permission-unverified"
144
- | "symlink" | "timeout" | "too-large" | "unsupported-platform";
151
+ | "already-exists" | "denied-path" | "device-path" | "hardlink"
152
+ | "helper-failed"
153
+ | "helper-unavailable" | "insecure-permissions" | "invalid-path"
154
+ | "not-empty" | "not-file" | "not-found" | "not-owned"
155
+ | "not-removable" | "outside-workspace" | "path-alias"
156
+ | "path-mismatch" | "permission-unverified" | "symlink"
157
+ | "timeout" | "too-large" | "unsupported-platform";
145
158
  ```
146
159
 
147
160
  Closed union you switch on. See the [Errors](errors.md) reference for what each one means.
package/docs/walk.md CHANGED
@@ -25,6 +25,7 @@ type WalkDirectoryResult = {
25
25
  entries: WalkDirectoryEntry[];
26
26
  scannedEntryCount: number;
27
27
  truncated: boolean;
28
+ failedDirs?: WalkDirectoryFailure[];
28
29
  };
29
30
 
30
31
  type WalkDirectoryEntry = {
@@ -35,10 +36,19 @@ type WalkDirectoryEntry = {
35
36
  kind: "file" | "directory" | "symlink" | "other";
36
37
  dirent: import("node:fs").Dirent;
37
38
  };
39
+
40
+ type WalkDirectoryFailure = {
41
+ path: string;
42
+ relativePath: string;
43
+ depth: number;
44
+ error: unknown;
45
+ };
38
46
  ```
39
47
 
40
48
  `depth` starts at `1` for direct children of `rootDir`. `relativePath` is always relative to the supplied root. `scannedEntryCount` counts directory entries examined, including entries filtered out by `include`.
41
49
 
50
+ `walkDirectory()` and `walkDirectorySync()` always return `failedDirs`; the property remains optional on the exported `WalkDirectoryResult` type so existing callers that manually construct the legacy result shape remain source-compatible. It lists every directory whose `realpath`/`readdir` threw, so its contents are absent from `entries`. `error` is the thrown value (a `NodeJS.ErrnoException` at runtime), so callers can distinguish a benign missing-directory race (`ENOENT`) from a real read failure (`EACCES`, `EIO`, `ESTALE`, …). The walk-root failure has an empty `relativePath` and `depth: 0`. Failures resolving a symlink's target kind are not reported here.
51
+
42
52
  ## Options
43
53
 
44
54
  ```ts
@@ -55,7 +65,7 @@ type WalkDirectoryOptions = {
55
65
 
56
66
  `include` controls which entries are returned. `descend` controls which directory entries are traversed. A skipped directory can still be returned if `include` accepts it.
57
67
 
58
- Unreadable directories are skipped. This makes the helper suitable for best-effort inventories and pruning jobs; use a stricter root-bounded operation when every entry must be accounted for.
68
+ Unreadable directories are skipped rather than throwing, but every skipped directory is recorded in `failedDirs`. This keeps the helper suitable for best-effort inventories while letting pruning jobs tell an incomplete scan from an empty one: a destructive reconcile that deletes state for paths missing from `entries` must first confirm `failedDirs` holds no real read failures, or a transient `EIO`/`EACCES` blip would be mistaken for mass deletion. Use a stricter root-bounded operation when every entry must be accounted for.
59
69
 
60
70
  ## See also
61
71
 
package/docs/writing.md CHANGED
@@ -24,6 +24,24 @@ await fs.mkdir("snapshots/2026/05");
24
24
 
25
25
  A failure at any point either leaves the destination at its previous contents or surfaces an `FsSafeError` — never a partially-written file at the destination path.
26
26
 
27
+ ## Denying mutations
28
+
29
+ All mutation verbs accept `denyMutations?: DenyMutationPolicy`, either as a root default or per-call option:
30
+
31
+ ```ts
32
+ const fs = await root("/srv/workspace", {
33
+ denyMutations: {
34
+ paths: ["/srv/workspace/.env"],
35
+ prefixes: ["/srv/workspace/.ssh"],
36
+ },
37
+ });
38
+
39
+ await fs.write(".env", "x"); // throws FsSafeError code "denied-path"
40
+ await fs.remove(".ssh/id_rsa"); // throws FsSafeError code "denied-path"
41
+ ```
42
+
43
+ `paths` blocks exact absolute paths. `prefixes` blocks absolute paths and everything below them. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a mutation through a symlinked ancestor to a denied path is still denied. Root-level and per-call policies are additive; per-call policy can add denies, but cannot clear root defaults.
44
+
27
45
  ## Write verbs
28
46
 
29
47
  ### `fs.write(rel, data, options?)`
@@ -35,7 +53,7 @@ await fs.write("state/last-run.json", JSON.stringify(run));
35
53
  await fs.write("notes/today.txt", "hello\n", { encoding: "utf8" });
36
54
  ```
37
55
 
38
- `data` accepts `string | Buffer`. `options` are `{ encoding?: BufferEncoding; mkdir?: boolean; mode?: number; overwrite?: boolean }`. `mode` sets the file's POSIX mode; if omitted, falls back to the `mode` from `RootDefaults` and then to umask. `overwrite` defaults to `true`; set it to `false` for the same no-clobber behavior as `create()`.
56
+ `data` accepts `string | Buffer`. `options` are `{ denyMutations?: DenyMutationPolicy; encoding?: BufferEncoding; mkdir?: boolean; mode?: number; overwrite?: boolean }`. `mode` sets the file's POSIX mode; if omitted, falls back to the `mode` from `RootDefaults` and then to umask. `overwrite` defaults to `true`; set it to `false` for the same no-clobber behavior as `create()`.
39
57
 
40
58
  ### `fs.create(rel, data, options?)`
41
59
 
@@ -75,14 +93,14 @@ type RootWriteJsonOptions = {
75
93
 
76
94
  ### `fs.append(rel, data, options?)`
77
95
 
78
- Open in append mode, write, close. Honors `mkdir` for the parent directory. Pass `prependNewlineIfNeeded: true` to insert a `\n` if the file does not already end in one.
96
+ Open in append mode, write, sync the file handle, and close. Honors `mkdir` for the parent directory and syncs the parent directory when the append creates the file. Pass `prependNewlineIfNeeded: true` to insert a `\n` if the file does not already end in one.
79
97
 
80
98
  ```ts
81
99
  await fs.append("logs/today.log", `[${ts}] ${line}\n`);
82
100
  await fs.append("notes/scratch.md", "* new bullet", { prependNewlineIfNeeded: true });
83
101
  ```
84
102
 
85
- For high-volume logging, consider [`openWritable`](#openwritable) and a long-lived append handle.
103
+ For high-volume logging, consider [`openWritable`](#openwritable) and a long-lived append handle. Direct append-mode writes preserve kernel append semantics, but they are not atomic against external rotators that rename or unlink the target.
86
104
 
87
105
  ### `fs.copyIn(rel, sourceAbsPath, options?)`
88
106
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.2.7",
3
+ "version": "0.4.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -97,29 +97,34 @@
97
97
  "default": "./dist/test-hooks.js"
98
98
  }
99
99
  },
100
+ "optionalDependencies": {
101
+ "jszip": "^3.10.1",
102
+ "tar": "7.5.16"
103
+ },
104
+ "devDependencies": {
105
+ "@types/node": "^22.19.20",
106
+ "@vitest/coverage-v8": "4.1.8",
107
+ "typescript": "^5.9.3",
108
+ "vitest": "^4.1.8"
109
+ },
110
+ "engines": {
111
+ "node": ">=22"
112
+ },
100
113
  "scripts": {
101
114
  "benchmark": "node scripts/benchmark.mjs",
102
115
  "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
103
116
  "lint:file-size": "node scripts/check-file-size.mjs",
104
117
  "lint:fs-boundary": "node scripts/check-fs-boundary-primitives.mjs",
105
- "prepack": "node scripts/prepack-build.mjs",
106
118
  "test": "vitest run",
107
119
  "test:coverage": "vitest run --coverage",
108
120
  "test:security": "vitest run test/fs-safe.test.ts test/read-boundary-bypass.test.ts test/write-boundary-bypass.test.ts test/additional-boundary-bypass.test.ts test/adversarial-boundary-payloads.test.ts",
109
121
  "check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm test",
110
- "docs:site": "node scripts/build-docs-site.mjs"
111
- },
112
- "optionalDependencies": {
113
- "jszip": "^3.10.1",
114
- "tar": "7.5.13"
115
- },
116
- "devDependencies": {
117
- "@types/node": "^22.15.19",
118
- "@vitest/coverage-v8": "4.1.6",
119
- "typescript": "^5.8.3",
120
- "vitest": "^4.1.6"
121
- },
122
- "engines": {
123
- "node": ">=20.11"
122
+ "docs:site": "node scripts/build-docs-site.mjs",
123
+ "check:changed": "pnpm run check",
124
+ "test:changed": "pnpm run test",
125
+ "crabbox:hydrate": "crabbox actions hydrate",
126
+ "crabbox:run": "crabbox run",
127
+ "crabbox:stop": "crabbox stop",
128
+ "crabbox:warmup": "crabbox warmup"
124
129
  }
125
- }
130
+ }