@openclaw/fs-safe 0.2.6 → 0.3.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 (56) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +21 -7
  3. package/dist/archive-deadline.d.ts +9 -0
  4. package/dist/archive-deadline.d.ts.map +1 -0
  5. package/dist/archive-deadline.js +67 -0
  6. package/dist/archive-file-io.d.ts +9 -0
  7. package/dist/archive-file-io.d.ts.map +1 -0
  8. package/dist/archive-file-io.js +11 -0
  9. package/dist/archive-staging.d.ts.map +1 -1
  10. package/dist/archive-staging.js +43 -3
  11. package/dist/archive.d.ts.map +1 -1
  12. package/dist/archive.js +213 -105
  13. package/dist/deny-mutations.d.ts +11 -0
  14. package/dist/deny-mutations.d.ts.map +1 -0
  15. package/dist/deny-mutations.js +102 -0
  16. package/dist/errors.d.ts +1 -1
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/json-durable-queue.d.ts.map +1 -1
  21. package/dist/json-durable-queue.js +5 -0
  22. package/dist/json.d.ts.map +1 -1
  23. package/dist/json.js +51 -4
  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/output.d.ts.map +1 -1
  28. package/dist/output.js +1 -0
  29. package/dist/pinned-write.js +3 -9
  30. package/dist/read-opened-file.d.ts +18 -0
  31. package/dist/read-opened-file.d.ts.map +1 -0
  32. package/dist/read-opened-file.js +15 -0
  33. package/dist/regular-file.d.ts.map +1 -1
  34. package/dist/regular-file.js +23 -3
  35. package/dist/root-impl.d.ts +18 -15
  36. package/dist/root-impl.d.ts.map +1 -1
  37. package/dist/root-impl.js +60 -100
  38. package/dist/root-paths.d.ts.map +1 -1
  39. package/dist/root-paths.js +16 -5
  40. package/dist/root.d.ts +1 -1
  41. package/dist/root.d.ts.map +1 -1
  42. package/dist/secret-file.d.ts +1 -0
  43. package/dist/secret-file.d.ts.map +1 -1
  44. package/dist/secret-file.js +23 -3
  45. package/dist/sidecar-lock.d.ts.map +1 -1
  46. package/dist/sidecar-lock.js +9 -3
  47. package/dist/symlink-parents.js +2 -2
  48. package/docs/errors.md +2 -0
  49. package/docs/python-helper.md +3 -3
  50. package/docs/root.md +19 -9
  51. package/docs/secret-file.md +3 -2
  52. package/docs/security-model.md +4 -0
  53. package/docs/timing.md +1 -1
  54. package/docs/types.md +21 -9
  55. package/docs/writing.md +19 -1
  56. package/package.json +1 -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
 
@@ -69,9 +75,9 @@ fs.append(rel, data, options?) // append text/buffer; respects mkdir d
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
@@ -102,14 +110,15 @@ operations that Node's `fs` API does not expose ergonomically.
102
110
  ```ts
103
111
  import { configureFsSafePython } from "@openclaw/fs-safe/config";
104
112
 
105
- configureFsSafePython({ mode: "off" }); // disable helper; some writes fail closed
113
+ configureFsSafePython({ mode: "off" }); // Node-only fallback path
106
114
  configureFsSafePython({ mode: "require" }); // fail if fd-relative helper unavailable
107
115
  ```
108
116
 
109
- `auto` is the default. Configure the mode before creating roots. On POSIX,
110
- write methods that require fd-relative parent commits fail closed without the
111
- helper. Use `require` when any helper loss should be treated as a deployment
112
- failure. See [Python helper policy](python-helper.md) for deployment guidance.
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.
113
122
 
114
123
  ### Properties
115
124
 
@@ -131,6 +140,7 @@ Every method throws `FsSafeError` with a `code`. Branch on `err.code`, not messa
131
140
  | `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). |
132
141
  | `not-file` | A read or copy targeted a non-regular file (directory, FIFO, socket, …). |
133
142
  | `already-exists` | `create()` or `move()` without `overwrite` hit an existing target. |
143
+ | `denied-path` | A mutation target matched `denyMutations.paths` or `denyMutations.prefixes`. |
134
144
  | `symlink` | A path component is a symlink, and the call's `symlinks` policy is `reject`. |
135
145
  | `hardlink` | The target's `nlink > 1` and `hardlinks` policy is `reject`. |
136
146
  | `path-mismatch` | Post-open identity check failed — the opened fd does not match the resolved path. |
@@ -36,7 +36,7 @@ The 16 KiB cap is intentionally aggressive — credentials should be small. If y
36
36
 
37
37
  ### `tryReadSecretFileSync(filePath, label, options?)`
38
38
 
39
- The lenient reader. Returns the trimmed secret string, or `undefined` when the path is missing, empty, unreadable, too large, or rejected by the validation checks.
39
+ The lenient reader. Returns the trimmed secret string, or `undefined` when the path is missing or blank. Validation failures, unreadable files, oversized files, symlinks, and hardlinks throw `FsSafeError` so callers fail closed on suspicious credential state.
40
40
 
41
41
  ```ts
42
42
  import { tryReadSecretFileSync } from "@openclaw/fs-safe/secret";
@@ -63,10 +63,11 @@ const token = readSecretFileSync("/var/lib/app/auth.token");
63
63
  type SecretFileReadOptions = {
64
64
  maxBytes?: number; // default DEFAULT_SECRET_FILE_MAX_BYTES (16 KiB)
65
65
  rejectSymlink?: boolean;
66
+ rejectHardlinks?: boolean; // default true
66
67
  };
67
68
  ```
68
69
 
69
- The reader trims the file content and rejects empty results. `rejectSymlink` blocks a symlink path before the pinned read.
70
+ 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.
70
71
 
71
72
  ## Writing
72
73
 
@@ -50,6 +50,10 @@ When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink >
50
50
 
51
51
  `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
52
 
53
+ ### Denied mutations
54
+
55
+ `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.
56
+
53
57
  ### Atomic writes
54
58
 
55
59
  `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.
package/docs/timing.md CHANGED
@@ -102,7 +102,7 @@ await extractArchive({
102
102
  });
103
103
  ```
104
104
 
105
- `extractArchive` already takes `timeoutMs` and uses `withTimeout` internally — you don't need to wrap it. Reach for `withTimeout` for operations that don't carry their own timeout knob.
105
+ `extractArchive` already takes `timeoutMs` and carries its own abort signal/deadline checks — you don't need to wrap it. Reach for `withTimeout` for operations that don't carry their own timeout knob.
106
106
 
107
107
  ### Disable in tests
108
108
 
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,12 @@ 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" | "hardlink" | "helper-failed"
152
+ | "helper-unavailable" | "insecure-permissions" | "invalid-path"
153
+ | "not-empty" | "not-file" | "not-found" | "not-owned"
154
+ | "not-removable" | "outside-workspace" | "path-alias"
155
+ | "path-mismatch" | "permission-unverified" | "symlink"
156
+ | "timeout" | "too-large" | "unsupported-platform";
145
157
  ```
146
158
 
147
159
  Closed union you switch on. See the [Errors](errors.md) reference for what each one means.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "license": "MIT",
6
6
  "repository": {