@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.
- package/CHANGELOG.md +17 -0
- package/README.md +21 -7
- package/dist/archive-deadline.d.ts +9 -0
- package/dist/archive-deadline.d.ts.map +1 -0
- package/dist/archive-deadline.js +67 -0
- package/dist/archive-file-io.d.ts +9 -0
- package/dist/archive-file-io.d.ts.map +1 -0
- package/dist/archive-file-io.js +11 -0
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +43 -3
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +213 -105
- package/dist/deny-mutations.d.ts +11 -0
- package/dist/deny-mutations.d.ts.map +1 -0
- package/dist/deny-mutations.js +102 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/json-durable-queue.d.ts.map +1 -1
- package/dist/json-durable-queue.js +5 -0
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +51 -4
- package/dist/opened-realpath.d.ts +3 -0
- package/dist/opened-realpath.d.ts.map +1 -0
- package/dist/opened-realpath.js +79 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +1 -0
- package/dist/pinned-write.js +3 -9
- package/dist/read-opened-file.d.ts +18 -0
- package/dist/read-opened-file.d.ts.map +1 -0
- package/dist/read-opened-file.js +15 -0
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +23 -3
- package/dist/root-impl.d.ts +18 -15
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +60 -100
- package/dist/root-paths.d.ts.map +1 -1
- package/dist/root-paths.js +16 -5
- package/dist/root.d.ts +1 -1
- package/dist/root.d.ts.map +1 -1
- package/dist/secret-file.d.ts +1 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +23 -3
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +9 -3
- package/dist/symlink-parents.js +2 -2
- package/docs/errors.md +2 -0
- package/docs/python-helper.md +3 -3
- package/docs/root.md +19 -9
- package/docs/secret-file.md +3 -2
- package/docs/security-model.md +4 -0
- package/docs/timing.md +1 -1
- package/docs/types.md +21 -9
- package/docs/writing.md +19 -1
- 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)
|
|
73
|
-
fs.mkdir(rel)
|
|
74
|
-
fs.ensureRoot()
|
|
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" }); //
|
|
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.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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. |
|
package/docs/secret-file.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/security-model.md
CHANGED
|
@@ -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
|
|
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" | "
|
|
141
|
-
| "
|
|
142
|
-
| "not-
|
|
143
|
-
| "
|
|
144
|
-
| "
|
|
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
|
|