@openclaw/fs-safe 0.1.0 → 0.1.2

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 (114) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +23 -3
  3. package/dist/archive-staging.d.ts.map +1 -1
  4. package/dist/archive-staging.js +51 -7
  5. package/dist/archive.js +2 -2
  6. package/dist/file-store-boundary.d.ts +27 -0
  7. package/dist/file-store-boundary.d.ts.map +1 -0
  8. package/dist/file-store-boundary.js +164 -0
  9. package/dist/file-store.d.ts.map +1 -1
  10. package/dist/file-store.js +62 -72
  11. package/dist/path.d.ts +1 -0
  12. package/dist/path.d.ts.map +1 -1
  13. package/dist/path.js +6 -3
  14. package/dist/private-file-store.d.ts +5 -7
  15. package/dist/private-file-store.d.ts.map +1 -1
  16. package/dist/private-file-store.js +21 -34
  17. package/dist/root-context.d.ts +23 -0
  18. package/dist/root-context.d.ts.map +1 -0
  19. package/dist/root-context.js +62 -0
  20. package/dist/root-errors.d.ts +4 -0
  21. package/dist/root-errors.d.ts.map +1 -0
  22. package/dist/root-errors.js +21 -0
  23. package/dist/root-impl.d.ts +108 -0
  24. package/dist/root-impl.d.ts.map +1 -0
  25. package/dist/{safe-root.js → root-impl.js} +464 -251
  26. package/dist/root.d.ts +1 -107
  27. package/dist/root.d.ts.map +1 -1
  28. package/dist/root.js +1 -1332
  29. package/docs/advanced.md +100 -0
  30. package/docs/archive.md +199 -0
  31. package/docs/atomic.md +163 -0
  32. package/docs/config.md +65 -0
  33. package/docs/contributing.md +80 -0
  34. package/docs/errors.md +154 -0
  35. package/docs/file-store.md +213 -0
  36. package/docs/filename.md +0 -0
  37. package/docs/index.md +76 -0
  38. package/docs/install-path.md +167 -0
  39. package/docs/install.md +150 -0
  40. package/docs/json-store.md +200 -0
  41. package/docs/json.md +158 -0
  42. package/docs/local-roots.md +164 -0
  43. package/docs/path-scope.md +130 -0
  44. package/docs/path.md +191 -0
  45. package/docs/permissions.md +95 -0
  46. package/docs/private-file-store.md +45 -0
  47. package/docs/python-helper.md +103 -0
  48. package/docs/quickstart.md +147 -0
  49. package/docs/reading.md +174 -0
  50. package/docs/regular-file.md +175 -0
  51. package/docs/root.md +205 -0
  52. package/docs/secret-file.md +154 -0
  53. package/docs/secure-file.md +82 -0
  54. package/docs/security-model.md +99 -0
  55. package/docs/sidecar-lock.md +229 -0
  56. package/docs/store.md +44 -0
  57. package/docs/temp.md +276 -0
  58. package/docs/test-hooks.md +74 -0
  59. package/docs/testing.md +168 -0
  60. package/docs/timing.md +121 -0
  61. package/docs/types.md +155 -0
  62. package/docs/walk.md +63 -0
  63. package/docs/writing.md +222 -0
  64. package/package.json +5 -2
  65. package/dist/archive-utils.d.ts +0 -3
  66. package/dist/archive-utils.d.ts.map +0 -1
  67. package/dist/archive-utils.js +0 -26
  68. package/dist/boundary-file-read.d.ts +0 -44
  69. package/dist/boundary-file-read.d.ts.map +0 -1
  70. package/dist/boundary-file-read.js +0 -129
  71. package/dist/boundary-path.d.ts +0 -39
  72. package/dist/boundary-path.d.ts.map +0 -1
  73. package/dist/boundary-path.js +0 -598
  74. package/dist/fs-pinned-path-helper.d.ts +0 -7
  75. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  76. package/dist/fs-pinned-path-helper.js +0 -182
  77. package/dist/fs-pinned-write-helper.d.ts +0 -21
  78. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  79. package/dist/fs-pinned-write-helper.js +0 -263
  80. package/dist/hardlink-guards.d.ts +0 -7
  81. package/dist/hardlink-guards.d.ts.map +0 -1
  82. package/dist/hardlink-guards.js +0 -30
  83. package/dist/install-safe-path.d.ts +0 -20
  84. package/dist/install-safe-path.d.ts.map +0 -1
  85. package/dist/install-safe-path.js +0 -94
  86. package/dist/json-file.d.ts +0 -3
  87. package/dist/json-file.d.ts.map +0 -1
  88. package/dist/json-file.js +0 -123
  89. package/dist/json-files.d.ts +0 -20
  90. package/dist/json-files.d.ts.map +0 -1
  91. package/dist/json-files.js +0 -153
  92. package/dist/path-alias-guards.d.ts +0 -19
  93. package/dist/path-alias-guards.d.ts.map +0 -1
  94. package/dist/path-alias-guards.js +0 -21
  95. package/dist/path-guards.d.ts +0 -7
  96. package/dist/path-guards.d.ts.map +0 -1
  97. package/dist/path-guards.js +0 -49
  98. package/dist/path-safety.d.ts +0 -12
  99. package/dist/path-safety.d.ts.map +0 -1
  100. package/dist/path-safety.js +0 -50
  101. package/dist/safe-open-sync.d.ts +0 -24
  102. package/dist/safe-open-sync.d.ts.map +0 -1
  103. package/dist/safe-open-sync.js +0 -71
  104. package/dist/safe-root.d.ts +0 -123
  105. package/dist/safe-root.d.ts.map +0 -1
  106. package/dist/secure-temp-workspace.d.ts +0 -25
  107. package/dist/secure-temp-workspace.d.ts.map +0 -1
  108. package/dist/secure-temp-workspace.js +0 -136
  109. package/dist/sibling-temp-file.d.ts +0 -16
  110. package/dist/sibling-temp-file.d.ts.map +0 -1
  111. package/dist/sibling-temp-file.js +0 -73
  112. package/dist/sibling-temp-write.d.ts +0 -8
  113. package/dist/sibling-temp-write.d.ts.map +0 -1
  114. package/dist/sibling-temp-write.js +0 -40
package/docs/root.md ADDED
@@ -0,0 +1,205 @@
1
+ # root()
2
+
3
+ `root()` is the primary entry point. It takes a trusted directory and returns a capability-style `Root` handle whose methods accept relative paths and refuse to escape the directory.
4
+
5
+ ```ts
6
+ import { root } from "@openclaw/fs-safe";
7
+
8
+ const fs = await root("/srv/workspace", {
9
+ hardlinks: "reject",
10
+ symlinks: "reject",
11
+ mkdir: true,
12
+ });
13
+ ```
14
+
15
+ ## Signature
16
+
17
+ ```ts
18
+ function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;
19
+
20
+ type RootDefaults = {
21
+ hardlinks?: "reject" | "allow"; // refuse files with nlink > 1 on read; defaults to "reject"
22
+ maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
23
+ mkdir?: boolean; // create missing parent dirs on write/openWritable/append
24
+ mode?: number; // file mode applied to new writes; per-call override available
25
+ nonBlockingRead?: boolean; // schedule reads on a worker; useful for large files
26
+ symlinks?: "reject" | "follow-within-root"; // policy when a path component is a symlink
27
+ };
28
+ ```
29
+
30
+ `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
+
32
+ `defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only.
33
+
34
+ ## The `Root` interface
35
+
36
+ Every method on the returned handle accepts paths relative to the root and rejects anything that would escape it.
37
+
38
+ ### Reads
39
+
40
+ ```ts
41
+ fs.read(rel, options?) // { buffer, realPath, stat }
42
+ fs.readBytes(rel, options?) // Buffer
43
+ fs.readText(rel, options?) // string
44
+ fs.readJson<T>(rel, options?) // parsed T
45
+ fs.open(rel, options?) // { handle, realPath, stat, [Symbol.asyncDispose] }
46
+ fs.readAbsolute(absPath, options?) // ReadResult; absPath must already be inside the root
47
+ fs.reader(options?) // (path) => Promise<Buffer>; useful for loader APIs
48
+ ```
49
+
50
+ `open()` returns a Node `FileHandle` for streaming. Prefer `await using` for cleanup:
51
+
52
+ ```ts
53
+ await using opened = await fs.open("large.log");
54
+ {
55
+ for await (const chunk of opened.handle.createReadStream()) {
56
+ process.stdout.write(chunk);
57
+ }
58
+ }
59
+ ```
60
+
61
+ ### Writes
62
+
63
+ ```ts
64
+ fs.write(rel, data, options?) // overwrite-ok atomic write
65
+ fs.create(rel, data, options?) // throws "already-exists" if target exists
66
+ fs.writeJson(rel, value, options?) // JSON.stringify + atomic write
67
+ fs.createJson(rel, value, options?) // create() variant of writeJson
68
+ fs.append(rel, data, options?) // append text/buffer; respects mkdir default
69
+ fs.copyIn(rel, sourceAbsPath, options?) // copy from outside the root, atomically, with size cap
70
+ fs.openWritable(rel, options?) // FileHandle for streaming writes; supports await using
71
+ 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
75
+ ```
76
+
77
+ `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`).
78
+
79
+ `copyIn` is a one-shot ingest from a trusted absolute source path: it streams the source through the boundary, atomically renames into the root, and respects `maxBytes`.
80
+
81
+ `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
+
83
+ ### Inspection (advisory)
84
+
85
+ ```ts
86
+ fs.exists(rel) // boolean
87
+ fs.stat(rel) // PathStat
88
+ fs.list(rel) // string[]
89
+ fs.list(rel, { withFileTypes }) // DirEntry[]
90
+ fs.resolve(rel) // absolute path inside the root, after canonicalization
91
+ ```
92
+
93
+ 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.
94
+
95
+ ## Python helper mode
96
+
97
+ On POSIX, mutation and inspection methods that need fd-relative directory
98
+ operations go through one persistent Python helper process. This avoids a
99
+ spawn-per-call cost while still using `openat`/`renameat`/`unlinkat`-style
100
+ operations that Node's `fs` API does not expose ergonomically.
101
+
102
+ ```ts
103
+ import { configureFsSafePython } from "@openclaw/fs-safe/config";
104
+
105
+ configureFsSafePython({ mode: "off" }); // Node-only fallback path
106
+ configureFsSafePython({ mode: "require" }); // fail if fd-relative helper unavailable
107
+ ```
108
+
109
+ `auto` is the default. Configure the mode before creating roots. Without the
110
+ helper, root methods still run, but same-UID races that swap parent directories
111
+ between validation and mutation are harder to close completely. Use `require`
112
+ when that downgrade should be treated as a deployment failure. See
113
+ [Python helper policy](python-helper.md) for deployment guidance.
114
+
115
+ ### Properties
116
+
117
+ ```ts
118
+ fs.rootDir // the directory you passed in
119
+ fs.rootReal // its canonical real path (after symlink resolution)
120
+ fs.rootWithSep // rootReal with a trailing separator, for prefix comparisons
121
+ fs.defaults // the RootDefaults you passed
122
+ ```
123
+
124
+ ## Failure semantics
125
+
126
+ Every method throws `FsSafeError` with a `code`. Branch on `err.code`, not message text. Common codes:
127
+
128
+ | Code | When it fires |
129
+ |---|---|
130
+ | `invalid-path` | The input path is malformed, including embedded NUL bytes. |
131
+ | `outside-workspace` | The input resolves outside the root, or contains a `..` segment that would escape it. |
132
+ | `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). |
133
+ | `not-file` | A read or copy targeted a non-regular file (directory, FIFO, socket, …). |
134
+ | `already-exists` | `create()` or `move()` without `overwrite` hit an existing target. |
135
+ | `symlink` | A path component is a symlink, and the call's `symlinks` policy is `reject`. |
136
+ | `hardlink` | The target's `nlink > 1` and `hardlinks` policy is `reject`. |
137
+ | `path-mismatch` | Post-open identity check failed — the opened fd does not match the resolved path. |
138
+ | `too-large` | Read exceeded `maxBytes`. |
139
+
140
+ Full list in the [Errors](errors.md) reference.
141
+
142
+ ## Defaults vs per-call options
143
+
144
+ Defaults reduce repetition; per-call options handle exceptions:
145
+
146
+ ```ts
147
+ const fs = await root("/srv/workspace", {
148
+ symlinks: "reject",
149
+ hardlinks: "reject",
150
+ mkdir: true,
151
+ });
152
+
153
+ // Default: symlinks rejected.
154
+ await fs.readText("config.toml");
155
+
156
+ // One specific path needs to follow a symlink that lands inside the root.
157
+ await fs.readText("links/current.log", { symlinks: "follow-within-root" });
158
+ ```
159
+
160
+ Text helpers default to UTF-8. Pass `encoding` per call to `readText`, `readJson`, `write`, `create`, or `append` when you need another encoding.
161
+
162
+ ## Common patterns
163
+
164
+ ### Read-only loader
165
+
166
+ ```ts
167
+ const fs = await root("/srv/workspace", { symlinks: "reject", hardlinks: "reject" });
168
+ const load = fs.reader();
169
+ const a = await load("notes/today.txt"); // relative
170
+ const b = await load("/srv/workspace/state.bin"); // absolute, but inside the root
171
+ ```
172
+
173
+ `fs.reader()` returns a `(path) => Promise<Buffer>` callback. Useful when wiring `fs-safe` into APIs that accept a generic loader function. Absolute paths outside the root are rejected with `outside-workspace`.
174
+
175
+ ### "Touch only if missing" seeding
176
+
177
+ ```ts
178
+ try {
179
+ await fs.create("config/seed.json", initialJson);
180
+ } catch (err) {
181
+ if (err instanceof FsSafeError && err.code === "already-exists") {
182
+ // existing config wins
183
+ } else {
184
+ throw err;
185
+ }
186
+ }
187
+ ```
188
+
189
+ ### Replace + verify
190
+
191
+ ```ts
192
+ await fs.write("state.json", JSON.stringify(state, null, 2));
193
+ const echoed = await fs.readJson<State>("state.json");
194
+ assertDeepEqual(echoed, state);
195
+ ```
196
+
197
+ `write` is atomic, so the file is either old or new — never half-written. Re-reading lets you detect a parallel writer, if one exists.
198
+
199
+ ## See also
200
+
201
+ - [Reading](reading.md) — read variants in depth, plus stream patterns.
202
+ - [Writing](writing.md) — write/create/move/remove in depth.
203
+ - [pathScope()](path-scope.md) — the same boundary semantics over an absolute path you already trust.
204
+ - [Atomic writes](atomic.md) — the lower-level helpers used by `fs.write`.
205
+ - [Errors](errors.md) — the closed code union you'll be catching.
@@ -0,0 +1,154 @@
1
+ # Secret files
2
+
3
+ Helpers for reading and writing credentials. Files are written at mode `0o600`, dirs at `0o700`, with a maximum read size to avoid OOM on bogus input.
4
+
5
+ ```ts
6
+ import {
7
+ readSecretFileSync,
8
+ tryReadSecretFileSync,
9
+ writeSecretFileAtomic,
10
+ DEFAULT_SECRET_FILE_MAX_BYTES,
11
+ PRIVATE_SECRET_DIR_MODE,
12
+ PRIVATE_SECRET_FILE_MODE,
13
+ } from "@openclaw/fs-safe/secret";
14
+ ```
15
+
16
+ ## When to use these vs `writeJson`
17
+
18
+ | Use these when | Use `writeJson` when |
19
+ |---|---|
20
+ | The file is a credential (token, key, password). | The file is application state. |
21
+ | You want the parent directory created at `0o700` if missing. | You don't care about the parent directory mode. |
22
+ | You want a hard size cap on reads (to defend against bogus input). | You're reading bounded JSON state. |
23
+ | Mode `0o600` is mandatory, not just nice. | Mode is whatever umask gives you. |
24
+
25
+ ## Constants
26
+
27
+ ```ts
28
+ DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024; // 16 KiB
29
+ PRIVATE_SECRET_DIR_MODE = 0o700;
30
+ PRIVATE_SECRET_FILE_MODE = 0o600;
31
+ ```
32
+
33
+ The 16 KiB cap is intentionally aggressive — credentials should be small. If you need bigger, pass `maxBytes` explicitly.
34
+
35
+ ## Reading
36
+
37
+ ### `tryReadSecretFileSync(filePath, label, options?)`
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.
40
+
41
+ ```ts
42
+ import { tryReadSecretFileSync } from "@openclaw/fs-safe/secret";
43
+
44
+ const token = tryReadSecretFileSync("/var/lib/app/auth.token", "auth token");
45
+ if (token) {
46
+ useToken(token);
47
+ } else {
48
+ await reauthenticate();
49
+ }
50
+ ```
51
+
52
+ ### `readSecretFileSync(filePath, label, options?)`
53
+
54
+ 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
+
56
+ ```ts
57
+ const token = readSecretFileSync("/var/lib/app/auth.token");
58
+ ```
59
+
60
+ ### Read options
61
+
62
+ ```ts
63
+ type SecretFileReadOptions = {
64
+ maxBytes?: number; // default DEFAULT_SECRET_FILE_MAX_BYTES (16 KiB)
65
+ rejectSymlink?: boolean;
66
+ };
67
+ ```
68
+
69
+ The reader trims the file content and rejects empty results. `rejectSymlink` blocks a symlink path before the pinned read.
70
+
71
+ ## Writing
72
+
73
+ ### `writeSecretFileAtomic(params)`
74
+
75
+ Async. Creates the parent directory at `dirMode` (default `0o700`) if missing, writes content to a sibling temp file at `mode` (default `0o600`), atomically renames over the destination, and re-asserts the file mode after rename.
76
+
77
+ ```ts
78
+ import { writeSecretFileAtomic } from "@openclaw/fs-safe/secret";
79
+
80
+ await writeSecretFileAtomic({
81
+ rootDir: "/var/lib/app",
82
+ filePath: "/var/lib/app/auth.token",
83
+ content: token,
84
+ });
85
+ ```
86
+
87
+ ### Parameters
88
+
89
+ ```ts
90
+ type WriteSecretFileParams = {
91
+ rootDir: string; // trusted root directory (created at dirMode if missing)
92
+ filePath: string; // absolute path; must be inside rootDir
93
+ content: string | Uint8Array;
94
+ mode?: number; // file mode for the new file (default PRIVATE_SECRET_FILE_MODE = 0o600)
95
+ dirMode?: number; // mode for the root and intermediate dirs (default PRIVATE_SECRET_DIR_MODE = 0o700)
96
+ };
97
+ ```
98
+
99
+ 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.
100
+
101
+ For more permissive credentials, override `mode`:
102
+
103
+ ```ts
104
+ await writeSecretFileAtomic({
105
+ rootDir: "/var/lib/app",
106
+ filePath: "/var/lib/app/readonly.token",
107
+ content: token,
108
+ mode: 0o400, // tighter than the default
109
+ });
110
+ ```
111
+
112
+ ## Common patterns
113
+
114
+ ### Load on boot, reauthenticate on miss
115
+
116
+ ```ts
117
+ const token = tryReadSecretFileSync("/var/lib/app/auth.token", "auth token");
118
+ if (!token) await runOauthFlow();
119
+ ```
120
+
121
+ ### Refresh and persist a token
122
+
123
+ ```ts
124
+ const fresh = await refreshToken(currentRefresh);
125
+ await writeSecretFileAtomic({
126
+ rootDir: "/var/lib/app",
127
+ filePath: "/var/lib/app/auth.token",
128
+ content: JSON.stringify(fresh),
129
+ });
130
+ ```
131
+
132
+ ### Compose with `withTimeout`
133
+
134
+ ```ts
135
+ import { withTimeout } from "@openclaw/fs-safe/advanced";
136
+
137
+ await withTimeout(
138
+ writeSecretFileAtomic({ rootDir, filePath, content }),
139
+ 5_000,
140
+ "persist auth token",
141
+ );
142
+ ```
143
+
144
+ ## Threat model notes
145
+
146
+ - These helpers protect the secret file from **other processes with the same UID** that respect filesystem permissions. They do not defend against root or against attackers who can read process memory.
147
+ - Validation failures are tripwires, not authorization. Investigate before clearing a rejected credential file.
148
+ - If the destination directory is on a tmpfs that does not honor mode bits, the helpers will set the mode bits but the OS may ignore them. Audit your platform.
149
+
150
+ ## See also
151
+
152
+ - [JSON files](json.md) — `writeJson` accepts `mode: 0o600` for non-secret JSON state.
153
+ - [Atomic writes](atomic.md) — the lower-level `replaceFileAtomic` used by these helpers.
154
+ - [Private file-store mode](private-file-store.md) — root-bounded JSON+text stores using secret-file write policy.
@@ -0,0 +1,82 @@
1
+ # Secure file reads
2
+
3
+ `readSecureFile()` is for absolute file paths that should be treated like credentials or other sensitive local inputs. It is stricter than `fs.readFile()` and different from `root().read()`: the file path is absolute, but the read is still fd-pinned and permission-checked before bytes are returned.
4
+
5
+ ```ts
6
+ import { readSecureFile } from "@openclaw/fs-safe/secure-file";
7
+
8
+ const { buffer, realPath, permissions } = await readSecureFile({
9
+ filePath: "/var/lib/app/auth.token",
10
+ label: "auth token",
11
+ trust: { trustedDirs: ["/var/lib/app"] },
12
+ io: { maxBytes: 16 * 1024, timeoutMs: 5_000 },
13
+ });
14
+ ```
15
+
16
+ ## Checks
17
+
18
+ The helper:
19
+
20
+ - requires a local absolute path and rejects UNC/network paths by default
21
+ - rejects directories and, by default, symlink paths
22
+ - opens the file before reading and verifies the opened fd still matches the path and realpath
23
+ - optionally requires the real path to live under one of `trust.trustedDirs`
24
+ - rejects hard-to-verify or unsafe permissions unless `permissions.allowInsecure` is set
25
+ - rejects files owned by another POSIX uid
26
+ - enforces `maxBytes` before and after reading
27
+ - closes the handle on success, error, and timeout
28
+
29
+ On POSIX, unsafe permissions mean group/world writable, and group/world readable unless `permissions.allowReadableByOthers` is true. On Windows, the helper uses the ACL inspection helpers from [`permissions`](permissions.md) and refuses the read if ACLs cannot be verified.
30
+
31
+ ## Options
32
+
33
+ ```ts
34
+ type SecureFileReadOptions = {
35
+ filePath: string;
36
+ label?: string;
37
+ trust?: {
38
+ trustedDirs?: string[];
39
+ allowSymlink?: boolean;
40
+ allowNetworkPath?: boolean;
41
+ };
42
+ permissions?: {
43
+ allowInsecure?: boolean;
44
+ allowReadableByOthers?: boolean;
45
+ };
46
+ inject?: {
47
+ platform?: NodeJS.Platform;
48
+ env?: NodeJS.ProcessEnv;
49
+ exec?: PermissionExec;
50
+ };
51
+ io?: {
52
+ maxBytes?: number;
53
+ timeoutMs?: number;
54
+ };
55
+ };
56
+ ```
57
+
58
+ `permissions.allowInsecure` is a migration escape hatch. Prefer fixing permissions and using [`formatPermissionRemediation`](permissions.md) to show the user what to run. `trust.allowNetworkPath` is off by default because UNC paths are remote authority, not local filesystem input. `inject` is for tests and platform adapters; production callers usually leave it unset.
59
+
60
+ ## Errors
61
+
62
+ `readSecureFile()` throws `FsSafeError` with codes such as:
63
+
64
+ | Code | Meaning |
65
+ |---|---|
66
+ | `invalid-path` | `filePath` was not a local absolute path. |
67
+ | `not-found` | The path could not be stat'd before open. |
68
+ | `not-file` | The opened target is not a regular file. |
69
+ | `symlink` | The path is a symlink and `trust.allowSymlink` is false. |
70
+ | `path-mismatch` | The path or realpath changed between open and verification. |
71
+ | `outside-workspace` | `realPath` is outside `trust.trustedDirs`. |
72
+ | `permission-unverified` | Required mode/ACL checks could not be completed. |
73
+ | `insecure-permissions` | Mode bits or ACLs grant broader access than allowed. |
74
+ | `not-owned` | POSIX owner uid is not the current process uid. |
75
+ | `too-large` | File size or bytes read exceeded `maxBytes`. |
76
+ | `timeout` | `timeoutMs` elapsed while reading. |
77
+
78
+ ## See also
79
+
80
+ - [Permissions](permissions.md) — standalone POSIX mode and Windows ACL checks.
81
+ - [Secret files](secret-file.md) — mode-0600 credential read/write helpers.
82
+ - [Reading](reading.md) — root-bounded relative reads.
@@ -0,0 +1,99 @@
1
+ # Security model
2
+
3
+ `fs-safe` is a library-level guardrail: a capability-style root handle for Node.js code that handles untrusted relative paths. It assumes the calling process already has whatever filesystem permissions it needs and aims to stop trivial path tricks from broadening that authority. It is not a sandbox and does not replace operating-system isolation.
4
+
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
+
7
+ ## Threat model
8
+
9
+ You hand a `root()` boundary to a piece of code that takes caller-controlled relative paths. The library defends against a caller that:
10
+
11
+ - supplies `..` traversal segments to escape the boundary
12
+ - supplies an absolute path where a relative one is expected
13
+ - replaces a path component with a symlink between check and use (TOCTOU)
14
+ - replaces the destination directory with a symlink right before a write
15
+ - creates a hardlink that aliases an out-of-tree inode and asks you to read or replace it
16
+ - triggers a partial write that leaves a half-written file at the destination
17
+ - ships an archive with `..` paths, absolute paths, or symlinks pointing outside the destination
18
+
19
+ It does **not** defend against:
20
+
21
+ - a process running with permissions to write anywhere on the filesystem and choosing to ignore the library
22
+ - 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
+ - container escape, TOCTOU between fork and exec of helpers, or kernel-level vulnerabilities
25
+ - semantic content checks: file types, archive payload schemas, signature verification
26
+
27
+ If you need full sandboxing, run the worker under reduced privileges (uid, container, seccomp, chroot, jail) and use `fs-safe` inside the sandbox to keep the worker honest about its own workspace.
28
+
29
+ ## Defenses, by failure mode
30
+
31
+ ### Path traversal and absolute paths
32
+
33
+ Every relative path is resolved against the canonicalized real path of the root, then checked with `isPathInside`. Inputs containing `..`, leading `/` (without `pathScope` opt-in), or that resolve outside the root throw `outside-workspace`.
34
+
35
+ ### Symlinks (read side)
36
+
37
+ `open()` and `read()` use `fs.open` with `O_NOFOLLOW` on POSIX where available. The library then `fstat`s the open fd and `realpath`s the original input, asserting the two refer to the same inode (`sameFileIdentity`). A symlink swap that happens between resolve and open will fail at the identity check rather than silently following the new target.
38
+
39
+ Per-call `symlinks: "follow-within-root"` allows symlinks whose final target is still inside the root. The default is `"reject"`.
40
+
41
+ ### Symlinks (write side)
42
+
43
+ Writes use a sibling-temp + rename helper that opens the parent directory by fd, then performs the rename `at` the parent fd. Replacing the parent directory with a symlink between the parent-fd open and the rename does not divert the write.
44
+
45
+ ### Hardlink aliasing
46
+
47
+ When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink > 1`, on the conservative assumption that a hardlinked file might alias an out-of-tree inode. This is defense-in-depth: the link count check is best-effort and platform-dependent. Treat it as a tripwire, not authorization.
48
+
49
+ ### TOCTOU between resolve and use
50
+
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
+
53
+ ### Atomic writes
54
+
55
+ `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.
56
+
57
+ Within one process, async writes to the same target are queued so their temp-write/rename phases do not overlap. Cross-process writers still need an external protocol such as the sidecar lock helpers.
58
+
59
+ ### Archive extraction
60
+
61
+ `extractArchive` first stages into a private temp directory (mode 0700) outside the destination, validates each entry path against `..` and absolute prefixes, refuses link-type entries by default, enforces entry count and byte budgets, and only then merges the staged tree into the destination through the same boundary checks used by direct writes.
62
+
63
+ ## What "library-level" means
64
+
65
+ A library cannot revoke its own caller's authority. If your code chooses to bypass `fs-safe` and call `fs.writeFile` directly with the same path, you bypass the defenses too. The contract `fs-safe` enforces is: *every filesystem operation that touches caller-controlled input goes through the boundary*. That contract is yours to keep.
66
+
67
+ 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.
68
+
69
+ ## Platform notes
70
+
71
+ - **POSIX (Linux, macOS):** Best-defended path. Uses `O_NOFOLLOW`, fd identity checks, and one persistent Python helper process for fd-relative `unlinkat` / `mkdirat` / `renameat` / parent-fd write operations. Configure `FS_SAFE_PYTHON_MODE=require` when helper startup must fail closed, or `off` when you need a no-Python runtime. See [Python helper policy](python-helper.md).
72
+ - **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.
73
+
74
+ The library does not advertise different security guarantees per platform — it advertises the same surface and relies on the strongest mechanism the platform offers.
75
+
76
+ ## Limitations to keep in mind
77
+
78
+ | Limitation | What it means |
79
+ |---|---|
80
+ | 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
+ | 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. |
83
+ | 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
+ | Hardlink rejection is best-effort | Link-count checks depend on platform metadata. Treat `hardlinks: "reject"` as a tripwire, not an authorization primitive. |
85
+ | 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. |
86
+ | Archive extraction is path safety, not content safety | Unsafe entry paths and links are rejected; malicious payload contents remain your application layer's problem. |
87
+ | Helper failures degrade fd-relative hardening | `helper-unavailable` falls back in `auto` mode and fails closed in `require` mode. Atomicity and identity checks remain, but parent-directory swaps between validation and mutation are less tightly pinned without the helper. |
88
+
89
+ ## Recommended deployment shape
90
+
91
+ - Run worker code under a dedicated UID with the smallest filesystem privileges that still allow the worker to do its job.
92
+ - Mount the workspace directory writable; mount everything else read-only or not at all.
93
+ - Use `fs-safe`'s `root()` for that workspace.
94
+ - For credentials, use [secret files](secret-file.md) (mode 0600 in mode-0700 dirs) rather than the workspace.
95
+ - For scratch space, use a [private temp workspace](temp.md) — don't reuse the workspace root.
96
+
97
+ ## Reporting issues
98
+
99
+ Suspected security issues belong in private disclosure first. See [`SECURITY.md`](https://github.com/openclaw/fs-safe/blob/main/SECURITY.md) in the repo for the current contact path.