@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.
- package/CHANGELOG.md +24 -0
- package/README.md +23 -3
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +51 -7
- package/dist/archive.js +2 -2
- package/dist/file-store-boundary.d.ts +27 -0
- package/dist/file-store-boundary.d.ts.map +1 -0
- package/dist/file-store-boundary.js +164 -0
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +62 -72
- package/dist/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +6 -3
- package/dist/private-file-store.d.ts +5 -7
- package/dist/private-file-store.d.ts.map +1 -1
- package/dist/private-file-store.js +21 -34
- package/dist/root-context.d.ts +23 -0
- package/dist/root-context.d.ts.map +1 -0
- package/dist/root-context.js +62 -0
- package/dist/root-errors.d.ts +4 -0
- package/dist/root-errors.d.ts.map +1 -0
- package/dist/root-errors.js +21 -0
- package/dist/root-impl.d.ts +108 -0
- package/dist/root-impl.d.ts.map +1 -0
- package/dist/{safe-root.js → root-impl.js} +464 -251
- package/dist/root.d.ts +1 -107
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +1 -1332
- package/docs/advanced.md +100 -0
- package/docs/archive.md +199 -0
- package/docs/atomic.md +163 -0
- package/docs/config.md +65 -0
- package/docs/contributing.md +80 -0
- package/docs/errors.md +154 -0
- package/docs/file-store.md +213 -0
- package/docs/filename.md +0 -0
- package/docs/index.md +76 -0
- package/docs/install-path.md +167 -0
- package/docs/install.md +150 -0
- package/docs/json-store.md +200 -0
- package/docs/json.md +158 -0
- package/docs/local-roots.md +164 -0
- package/docs/path-scope.md +130 -0
- package/docs/path.md +191 -0
- package/docs/permissions.md +95 -0
- package/docs/private-file-store.md +45 -0
- package/docs/python-helper.md +103 -0
- package/docs/quickstart.md +147 -0
- package/docs/reading.md +174 -0
- package/docs/regular-file.md +175 -0
- package/docs/root.md +205 -0
- package/docs/secret-file.md +154 -0
- package/docs/secure-file.md +82 -0
- package/docs/security-model.md +99 -0
- package/docs/sidecar-lock.md +229 -0
- package/docs/store.md +44 -0
- package/docs/temp.md +276 -0
- package/docs/test-hooks.md +74 -0
- package/docs/testing.md +168 -0
- package/docs/timing.md +121 -0
- package/docs/types.md +155 -0
- package/docs/walk.md +63 -0
- package/docs/writing.md +222 -0
- package/package.json +5 -2
- package/dist/archive-utils.d.ts +0 -3
- package/dist/archive-utils.d.ts.map +0 -1
- package/dist/archive-utils.js +0 -26
- package/dist/boundary-file-read.d.ts +0 -44
- package/dist/boundary-file-read.d.ts.map +0 -1
- package/dist/boundary-file-read.js +0 -129
- package/dist/boundary-path.d.ts +0 -39
- package/dist/boundary-path.d.ts.map +0 -1
- package/dist/boundary-path.js +0 -598
- package/dist/fs-pinned-path-helper.d.ts +0 -7
- package/dist/fs-pinned-path-helper.d.ts.map +0 -1
- package/dist/fs-pinned-path-helper.js +0 -182
- package/dist/fs-pinned-write-helper.d.ts +0 -21
- package/dist/fs-pinned-write-helper.d.ts.map +0 -1
- package/dist/fs-pinned-write-helper.js +0 -263
- package/dist/hardlink-guards.d.ts +0 -7
- package/dist/hardlink-guards.d.ts.map +0 -1
- package/dist/hardlink-guards.js +0 -30
- package/dist/install-safe-path.d.ts +0 -20
- package/dist/install-safe-path.d.ts.map +0 -1
- package/dist/install-safe-path.js +0 -94
- package/dist/json-file.d.ts +0 -3
- package/dist/json-file.d.ts.map +0 -1
- package/dist/json-file.js +0 -123
- package/dist/json-files.d.ts +0 -20
- package/dist/json-files.d.ts.map +0 -1
- package/dist/json-files.js +0 -153
- package/dist/path-alias-guards.d.ts +0 -19
- package/dist/path-alias-guards.d.ts.map +0 -1
- package/dist/path-alias-guards.js +0 -21
- package/dist/path-guards.d.ts +0 -7
- package/dist/path-guards.d.ts.map +0 -1
- package/dist/path-guards.js +0 -49
- package/dist/path-safety.d.ts +0 -12
- package/dist/path-safety.d.ts.map +0 -1
- package/dist/path-safety.js +0 -50
- package/dist/safe-open-sync.d.ts +0 -24
- package/dist/safe-open-sync.d.ts.map +0 -1
- package/dist/safe-open-sync.js +0 -71
- package/dist/safe-root.d.ts +0 -123
- package/dist/safe-root.d.ts.map +0 -1
- package/dist/secure-temp-workspace.d.ts +0 -25
- package/dist/secure-temp-workspace.d.ts.map +0 -1
- package/dist/secure-temp-workspace.js +0 -136
- package/dist/sibling-temp-file.d.ts +0 -16
- package/dist/sibling-temp-file.d.ts.map +0 -1
- package/dist/sibling-temp-file.js +0 -73
- package/dist/sibling-temp-write.d.ts +0 -8
- package/dist/sibling-temp-write.d.ts.map +0 -1
- 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.
|